├── flow ├── cssStub.js.flow └── shallowEqual.js.flow ├── demo ├── favicon.ico ├── images │ ├── ak.png │ ├── al.png │ ├── ar.png │ ├── az.png │ ├── ca.png │ ├── co.png │ ├── ct.png │ ├── de.png │ ├── fl.png │ ├── ga.png │ ├── hi.png │ ├── ia.png │ ├── id.png │ ├── il.png │ ├── in.png │ ├── ks.png │ ├── ky.png │ ├── la.png │ ├── logo.png │ ├── ma.png │ ├── md.png │ ├── me.png │ ├── mi.png │ ├── mn.png │ ├── mo.png │ ├── ms.png │ ├── mt.png │ ├── nc.png │ ├── nd.png │ ├── ne.png │ ├── nh.png │ ├── nj.png │ ├── nm.png │ ├── nv.png │ ├── ny.png │ ├── oh.png │ ├── ok.png │ ├── or.png │ ├── pa.png │ ├── ri.png │ ├── sc.png │ ├── sd.png │ ├── tn.png │ ├── tx.png │ ├── ut.png │ ├── va.png │ ├── vt.png │ ├── wa.png │ ├── wi.png │ ├── wv.png │ └── wy.png ├── favicon-16x16.png ├── favicon-32x32.png ├── apple-touch-icon.png ├── mstile-150x150.png ├── android-chrome-192x192.png ├── android-chrome-512x512.png ├── examples │ ├── NamePrefix.js │ ├── .eslintrc │ ├── Number.js │ ├── Browser.js │ ├── Note.js │ ├── Tags.js │ ├── StateProvince.js │ ├── GithubRepo.js │ └── Country.js ├── .babelrc ├── sections │ ├── Anchor.js │ ├── SizeSelect.js │ ├── ValidationSelect.js │ ├── NonStringSection.js │ ├── BasicSection.js │ ├── CodeMirror-prefold.js │ ├── PlaygroundSection.js │ ├── ListAdapterSection.js │ ├── ReactBootstrapSection.js │ ├── DynamicSection.js │ ├── ItemAdapterSection.js │ ├── ItemsAsValuesSection.js │ ├── MultipleSection.js │ └── Playground.js ├── browserconfig.xml ├── manifest.json ├── index.ejs ├── demo.js ├── safari-pinned-tab.svg └── demo.scss ├── .gitignore ├── src ├── types.js ├── EmptyListAdapter.js ├── ArrayListAdapter.js ├── KeyedListAdapter.js ├── ListAdapter.js ├── MapListAdapter.js ├── ObjectListAdapter.js ├── Suggestions.js ├── ItemAdapter.js ├── Choices.js ├── Autosuggest.scss └── Autosuggest.js ├── test └── .eslintrc ├── .flowconfig ├── webpack ├── test.config.babel.js ├── test-coverage.config.babel.js ├── base.config.babel.js ├── webpack.config.babel.js └── demo.config.babel.js ├── LICENSE ├── .eslintrc ├── .babelrc ├── karma.conf.js ├── package.json ├── README.md └── gulpfile.babel.js /flow/cssStub.js.flow: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flow/shallowEqual.js.flow: -------------------------------------------------------------------------------- 1 | declare export function shallowEqual(a: any, b: any): bool 2 | -------------------------------------------------------------------------------- /demo/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/favicon.ico -------------------------------------------------------------------------------- /demo/images/ak.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ak.png -------------------------------------------------------------------------------- /demo/images/al.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/al.png -------------------------------------------------------------------------------- /demo/images/ar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ar.png -------------------------------------------------------------------------------- /demo/images/az.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/az.png -------------------------------------------------------------------------------- /demo/images/ca.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ca.png -------------------------------------------------------------------------------- /demo/images/co.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/co.png -------------------------------------------------------------------------------- /demo/images/ct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ct.png -------------------------------------------------------------------------------- /demo/images/de.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/de.png -------------------------------------------------------------------------------- /demo/images/fl.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/fl.png -------------------------------------------------------------------------------- /demo/images/ga.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ga.png -------------------------------------------------------------------------------- /demo/images/hi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/hi.png -------------------------------------------------------------------------------- /demo/images/ia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ia.png -------------------------------------------------------------------------------- /demo/images/id.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/id.png -------------------------------------------------------------------------------- /demo/images/il.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/il.png -------------------------------------------------------------------------------- /demo/images/in.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/in.png -------------------------------------------------------------------------------- /demo/images/ks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ks.png -------------------------------------------------------------------------------- /demo/images/ky.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ky.png -------------------------------------------------------------------------------- /demo/images/la.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/la.png -------------------------------------------------------------------------------- /demo/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/logo.png -------------------------------------------------------------------------------- /demo/images/ma.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ma.png -------------------------------------------------------------------------------- /demo/images/md.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/md.png -------------------------------------------------------------------------------- /demo/images/me.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/me.png -------------------------------------------------------------------------------- /demo/images/mi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/mi.png -------------------------------------------------------------------------------- /demo/images/mn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/mn.png -------------------------------------------------------------------------------- /demo/images/mo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/mo.png -------------------------------------------------------------------------------- /demo/images/ms.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ms.png -------------------------------------------------------------------------------- /demo/images/mt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/mt.png -------------------------------------------------------------------------------- /demo/images/nc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/nc.png -------------------------------------------------------------------------------- /demo/images/nd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/nd.png -------------------------------------------------------------------------------- /demo/images/ne.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ne.png -------------------------------------------------------------------------------- /demo/images/nh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/nh.png -------------------------------------------------------------------------------- /demo/images/nj.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/nj.png -------------------------------------------------------------------------------- /demo/images/nm.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/nm.png -------------------------------------------------------------------------------- /demo/images/nv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/nv.png -------------------------------------------------------------------------------- /demo/images/ny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ny.png -------------------------------------------------------------------------------- /demo/images/oh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/oh.png -------------------------------------------------------------------------------- /demo/images/ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ok.png -------------------------------------------------------------------------------- /demo/images/or.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/or.png -------------------------------------------------------------------------------- /demo/images/pa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/pa.png -------------------------------------------------------------------------------- /demo/images/ri.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ri.png -------------------------------------------------------------------------------- /demo/images/sc.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/sc.png -------------------------------------------------------------------------------- /demo/images/sd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/sd.png -------------------------------------------------------------------------------- /demo/images/tn.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/tn.png -------------------------------------------------------------------------------- /demo/images/tx.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/tx.png -------------------------------------------------------------------------------- /demo/images/ut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/ut.png -------------------------------------------------------------------------------- /demo/images/va.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/va.png -------------------------------------------------------------------------------- /demo/images/vt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/vt.png -------------------------------------------------------------------------------- /demo/images/wa.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/wa.png -------------------------------------------------------------------------------- /demo/images/wi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/wi.png -------------------------------------------------------------------------------- /demo/images/wv.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/wv.png -------------------------------------------------------------------------------- /demo/images/wy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/images/wy.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .npmrc 3 | coverage/ 4 | dist/ 5 | lib/ 6 | node_modules/ 7 | npm-debug.log 8 | site/ 9 | -------------------------------------------------------------------------------- /demo/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/favicon-16x16.png -------------------------------------------------------------------------------- /demo/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/favicon-32x32.png -------------------------------------------------------------------------------- /demo/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/apple-touch-icon.png -------------------------------------------------------------------------------- /demo/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/mstile-150x150.png -------------------------------------------------------------------------------- /demo/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/android-chrome-192x192.png -------------------------------------------------------------------------------- /demo/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/affinipay/react-bootstrap-autosuggest/HEAD/demo/android-chrome-512x512.png -------------------------------------------------------------------------------- /demo/examples/NamePrefix.js: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /src/types.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import React from 'react' 4 | 5 | export type Node = number | string | React.Element<*> | 6 | (number | string | React.Element<*>)[] 7 | -------------------------------------------------------------------------------- /demo/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", 4 | "react" 5 | ], 6 | "plugins": [ 7 | "transform-class-properties", 8 | "transform-object-rest-spread" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "mocha": true 4 | }, 5 | "plugins": [ 6 | "mocha" 7 | ], 8 | "globals": { 9 | "sinon": false 10 | }, 11 | "rules": { 12 | "no-console": 0 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /demo/examples/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": { 3 | "Autosuggest": false, 4 | "React": false, 5 | "ReactDOM": false, 6 | "mountNode": false 7 | }, 8 | "rules": { 9 | "no-undef": 0, 10 | "react/jsx-no-undef": 0 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /demo/sections/Anchor.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function Anchor(props) { 4 | return ( 5 | 6 | # 7 | {props.children} 8 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /demo/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /demo/examples/Number.js: -------------------------------------------------------------------------------- 1 | 2 | Pick a number 3 | 9 | 10 | -------------------------------------------------------------------------------- /src/EmptyListAdapter.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import ListAdapter from './ListAdapter' 4 | 5 | export default class EmptyListAdapter extends ListAdapter { 6 | getLength() { 7 | return 0 8 | } 9 | 10 | filter(): I[] { 11 | return [] 12 | } 13 | 14 | find(): ?I { 15 | } 16 | 17 | toArray(): I[] { 18 | return [] 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /demo/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "React Bootstrap Autosuggest", 3 | "icons": [ 4 | { 5 | "src": "\/android-chrome-192x192.png", 6 | "sizes": "192x192", 7 | "type": "image\/png" 8 | }, 9 | { 10 | "src": "\/android-chrome-512x512.png", 11 | "sizes": "512x512", 12 | "type": "image\/png" 13 | } 14 | ], 15 | "theme_color": "#ffffff", 16 | "display": "standalone" 17 | } 18 | -------------------------------------------------------------------------------- /demo/examples/Browser.js: -------------------------------------------------------------------------------- 1 | 3 | Browser 4 | 10 | {validationState && } 11 | {validationState && Please select a browser} 12 | 13 | -------------------------------------------------------------------------------- /.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | .*/node_modules/chalk/.* 3 | .*/node_modules/editions/.* 4 | .*/node_modules/fbjs/.* 5 | .*/node_modules/.*/test/.* 6 | 7 | [options] 8 | esproposal.class_static_fields=enable 9 | esproposal.decorators=ignore 10 | module.name_mapper='^fbjs/lib/shallowEqual$' -> '/flow/shallowEqual.js.flow' 11 | module.name_mapper.extension='css' -> '/flow/cssStub.js.flow' 12 | module.name_mapper.extension='scss' -> '/flow/cssStub.js.flow' 13 | suppress_comment= \\(.\\|\n\\)*\\$FlowFixMe 14 | -------------------------------------------------------------------------------- /src/ArrayListAdapter.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import ListAdapter from './ListAdapter' 4 | 5 | export default class ArrayListAdapter extends ListAdapter { 6 | getLength(list: I[]) { 7 | return list.length 8 | } 9 | 10 | filter(list: I[], predicate: (item: I) => boolean): I[] { 11 | return list.filter(predicate) 12 | } 13 | 14 | find(list: I[], predicate: (item: I) => boolean): ?I { 15 | return list.find(predicate) 16 | } 17 | 18 | toArray(list: I[]): I[] { 19 | return list.slice() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /webpack/test.config.babel.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { getBaseConfig, jsLoader, sassLoader } from './base.config.babel' 3 | 4 | export default { 5 | ...getBaseConfig(), 6 | 7 | // entry points and output configured by karma-webpack 8 | 9 | module: { 10 | loaders: [ 11 | jsLoader, 12 | sassLoader 13 | ] 14 | }, 15 | 16 | resolve: { 17 | alias: { 18 | 'react-bootstrap-autosuggest': '../src/Autosuggest.js' 19 | }, 20 | modules: [ 21 | path.resolve('src'), 22 | 'node_modules' 23 | ] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /demo/sections/SizeSelect.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | ControlLabel, 4 | FormControl, 5 | FormGroup 6 | } from 'react-bootstrap' 7 | 8 | export default function sizeSelect(props) { 9 | return ( 10 | 11 | Form/input group size 12 | 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /demo/sections/ValidationSelect.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | ControlLabel, 4 | FormControl, 5 | FormGroup 6 | } from 'react-bootstrap' 7 | 8 | export default function ValidationSelect(props) { 9 | return ( 10 | 11 | Validation state 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /webpack/test-coverage.config.babel.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { jsLoader, sassLoader } from './base.config.babel' 3 | import testConfig from './test.config.babel' 4 | 5 | const paths = { 6 | SRC: path.resolve('src'), 7 | TEST: path.resolve('test') 8 | } 9 | 10 | export default { 11 | ...testConfig, 12 | 13 | module: { 14 | loaders: [ 15 | { 16 | test: /\.js$/, 17 | enforce: 'pre', 18 | loader: 'babel-istanbul-loader', 19 | options: { 20 | cacheDirectory: true 21 | }, 22 | include: paths.SRC, 23 | exclude: /node_modules/ 24 | }, 25 | { 26 | ...jsLoader, 27 | include: [paths.TEST] 28 | }, 29 | sassLoader 30 | ] 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /demo/examples/Note.js: -------------------------------------------------------------------------------- 1 | // import Autosuggest, { ListAdapter } from 'react-bootstrap-autosuggest' 2 | 3 | class NoteListAdapter extends ListAdapter { 4 | getLength(s) { 5 | return s.length 6 | } 7 | toArray(s) { 8 | return [...s].map((e, i) => ({ 9 | key: i, 10 | value: e !== '#' ? e : s[i - 1] + '#' 11 | })) 12 | } 13 | } 14 | NoteListAdapter.instance = new NoteListAdapter() 15 | 16 | return function render() { 17 | return 18 | Pick a note 19 | 25 | 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2016, AffiniPay LLC 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "parser": "babel-eslint", 4 | "parserOptions": { 5 | "ecmaVersion": 6, 6 | "sourceType": "module", 7 | "ecmaFeatures": { 8 | "impliedStrict": true, 9 | "jsx": true 10 | }, 11 | }, 12 | "env": { 13 | "browser": true, 14 | "es6": true, 15 | "node": true 16 | }, 17 | "globals": { 18 | "SyntheticEvent": false, 19 | "SyntheticInputEvent": false, 20 | "SyntheticKeyboardEvent": false 21 | }, 22 | "plugins": [ 23 | "react" 24 | ], 25 | "rules": { 26 | "jsx-quotes": 1, 27 | "no-console": ["warn"], 28 | "no-unused-vars": [2, { "vars": "all", "args": "after-used", "argsIgnorePattern": "^_" }], 29 | "quotes": [2, "single"], 30 | "react/jsx-no-bind": 1, 31 | "react/jsx-no-undef": 1, 32 | "react/jsx-uses-react": 1, 33 | "react/jsx-uses-vars": 1, 34 | "semi": [2, "never"] 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/KeyedListAdapter.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import ListAdapter from './ListAdapter' 4 | 5 | export default class KeyedListAdapter extends ListAdapter { 6 | itemKeyPropName: string; 7 | 8 | constructor(itemKeyPropName: string = 'key') { 9 | super() 10 | this.itemKeyPropName = itemKeyPropName 11 | } 12 | 13 | _getKeyValueItem(key: string, value: V): Object { 14 | const { itemKeyPropName } = this 15 | // istanbul ignore next 16 | const { itemValuePropName = 'value' } = this.props 17 | if (typeof value === 'object' && itemValuePropName in (value: any)) { 18 | if ((value: any)[itemKeyPropName] === key) { 19 | return (value: any) 20 | } else { 21 | return { 22 | [itemKeyPropName]: key, 23 | ...value 24 | } 25 | } 26 | } 27 | return { 28 | [itemKeyPropName]: key, 29 | [itemValuePropName]: value 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /demo/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%= htmlWebpackPlugin.options.title %> 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /src/ListAdapter.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { Props } from './ItemAdapter' 4 | 5 | import ItemAdapter from './ItemAdapter' 6 | 7 | export default class ListAdapter { 8 | props: Props; 9 | itemAdapter: ItemAdapter; 10 | 11 | receiveProps(props: Props, itemAdapter: ItemAdapter) { 12 | this.props = props 13 | this.itemAdapter = itemAdapter 14 | } 15 | 16 | isEmpty(list: L): boolean { 17 | return !this.getLength(list) 18 | } 19 | 20 | +getLength: (list: L) => number; 21 | 22 | filter(list: L, predicate: (item: I) => boolean): I[] { 23 | return this.toArray(list).filter(predicate) 24 | } 25 | 26 | find(list: L, predicate: (item: I) => boolean): ?I { 27 | return this.toArray(list).find(predicate) 28 | } 29 | 30 | findMatching(list: L, inputValue: string): ?I { 31 | const foldedValue = this.itemAdapter.foldValue(inputValue) 32 | return this.find(list, item => this.itemAdapter.itemMatchesInput(item, foldedValue)) 33 | } 34 | 35 | +toArray: (list: L) => I[]; 36 | } 37 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "check-es2015-constants", 4 | "react-hot-loader/babel", 5 | "syntax-flow", 6 | "syntax-jsx", 7 | "transform-class-properties", 8 | "transform-es2015-arrow-functions", 9 | "transform-es2015-block-scoped-functions", 10 | "transform-es2015-block-scoping", 11 | "transform-es2015-classes", 12 | "transform-es2015-computed-properties", 13 | "transform-es2015-destructuring", 14 | "transform-es2015-duplicate-keys", 15 | "transform-es2015-for-of", 16 | "transform-es2015-function-name", 17 | "transform-es2015-literals", 18 | "transform-es2015-modules-commonjs", 19 | "transform-es2015-object-super", 20 | "transform-es2015-parameters", 21 | "transform-es2015-shorthand-properties", 22 | "transform-es2015-spread", 23 | "transform-es2015-template-literals", 24 | "transform-es3-member-expression-literals", 25 | "transform-es3-property-literals", 26 | "transform-flow-strip-types", 27 | "transform-object-rest-spread", 28 | "transform-react-jsx" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/MapListAdapter.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import KeyedListAdapter from './KeyedListAdapter' 4 | 5 | export default class MapListAdapter 6 | extends KeyedListAdapter> { 7 | 8 | getLength(list: Map): number { 9 | return list.size 10 | } 11 | 12 | filter(list: Map, predicate: (item: Object) => boolean): Object[] { 13 | const result = [] 14 | for (let entry of list.entries()) { 15 | const item = this._getKeyValueItem(entry[0], entry[1]) 16 | if (predicate(item)) { 17 | result.push(item) 18 | } 19 | } 20 | return result 21 | } 22 | 23 | find(list: Map, predicate: (item: Object) => boolean): ?Object { 24 | for (let entry of list.entries()) { 25 | const item = this._getKeyValueItem(entry[0], entry[1]) 26 | if (predicate(item)) { 27 | return item 28 | } 29 | } 30 | } 31 | 32 | toArray(list: Map): Object[] { 33 | const result = [] 34 | for (let entry of list.entries()) { 35 | result.push(this._getKeyValueItem(entry[0], entry[1])) 36 | } 37 | return result 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /demo/sections/NonStringSection.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { ControlLabel, FormGroup } from 'react-bootstrap' 3 | import Autosuggest from 'react-bootstrap-autosuggest' 4 | import Anchor from './Anchor' 5 | import Playground from './Playground' 6 | const Number = require('raw-loader!../examples/Number').trim() 7 | 8 | const scope = { Autosuggest, ControlLabel, FormGroup } 9 | 10 | export default function NonStringSection() { 11 | return ( 12 |
13 |

Non-string options

14 | 15 |

16 | The items in the datalist property need not be strings. 17 | Numeric items are supported natively, as well as objects with particular 18 | property names. (Arbitrary objects are also supported by specifying 19 | the relevant property names or providing an item adapter, 20 | both of which are discussed in the next section.) This example also demonstrates 21 | the Bootstrap feature of input group add-ons. 22 |

23 | 24 | 25 |
26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /src/ObjectListAdapter.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import KeyedListAdapter from './KeyedListAdapter' 4 | 5 | export default class ObjectListAdapter 6 | extends KeyedListAdapter { 7 | 8 | getLength(list: { [key: string]: V }): number { 9 | return Object.keys(list).length 10 | } 11 | 12 | filter(list: { [key: string]: V }, predicate: (item: Object) => boolean): Object[] { 13 | const result = [] 14 | for (let key of Object.keys(list)) { 15 | const item = this._getKeyValueItem(key, list[key]) 16 | if (predicate(item)) { 17 | result.push(item) 18 | } 19 | } 20 | return result 21 | } 22 | 23 | find(list: { [key: string]: V }, predicate: (item: Object) => boolean): ?Object { 24 | for (let key of Object.keys(list)) { 25 | const item = this._getKeyValueItem(key, list[key]) 26 | if (predicate(item)) { 27 | return item 28 | } 29 | } 30 | } 31 | 32 | toArray(list: { [key: string]: V }): Object[] { 33 | const result = [] 34 | for (let key of Object.keys(list)) { 35 | result.push(this._getKeyValueItem(key, list[key])) 36 | } 37 | return result 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /webpack/base.config.babel.js: -------------------------------------------------------------------------------- 1 | import webpack from 'webpack' 2 | import yargs from 'yargs' 3 | 4 | export const cmdLineOptions = yargs 5 | .alias('p', 'optimize-minimize') 6 | .alias('d', 'debug') 7 | .option('host', { 8 | default: 'localhost', 9 | type: 'string' 10 | }) 11 | .option('port', { 12 | default: '8080', 13 | type: 'string' 14 | }) 15 | .argv 16 | 17 | export const jsLoaderName = 'babel-loader' 18 | export const jsLoader = { 19 | test: /\.js$/, 20 | loader: jsLoaderName, 21 | options: { 22 | cacheDirectory: true 23 | }, 24 | exclude: /node_modules/ 25 | } 26 | 27 | export const cssLoader = { 28 | test: /\.css$/, 29 | loaders: [ 30 | 'style-loader', 31 | 'css-loader' 32 | ] 33 | } 34 | 35 | export const sassLoader = { 36 | test: /\.scss$/, 37 | loaders: [ 38 | 'style-loader', 39 | 'css-loader', 40 | 'sass-loader?includePaths[]=./node_modules/bootstrap-sass/assets/stylesheets' 41 | ] 42 | } 43 | 44 | export function getBaseConfig(options = cmdLineOptions) { 45 | return { 46 | entry: undefined, 47 | 48 | output: undefined, 49 | 50 | externals: undefined, 51 | 52 | devtool: 'source-map', 53 | 54 | module: { 55 | loaders: [ 56 | { test: /\.js$/, enforce: 'pre', loader: 'eslint-loader', exclude: /node_modules/ } 57 | ] 58 | }, 59 | 60 | plugins: [ 61 | new webpack.DefinePlugin({ 62 | 'process.env': { 63 | 'NODE_ENV': JSON.stringify(options.optimizeMinimize ? 'production' : 'development') 64 | } 65 | }) 66 | ] 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /webpack/webpack.config.babel.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { cmdLineOptions, getBaseConfig, jsLoader, sassLoader } from './base.config.babel' 3 | 4 | export function withOptions(options = cmdLineOptions) { 5 | return { 6 | ...getBaseConfig(options), 7 | 8 | entry: { 9 | 'react-bootstrap-autosuggest': path.resolve('src/Autosuggest.js') 10 | }, 11 | 12 | output: { 13 | path: path.resolve('dist'), 14 | filename: options.optimizeMinimize ? '[name].min.js' : '[name].js', 15 | library: 'ReactBootstrapAutosuggest', 16 | libraryTarget: 'umd' 17 | }, 18 | 19 | module: { 20 | loaders: [ 21 | { 22 | ...jsLoader, 23 | options: { 24 | ...jsLoader.options, 25 | plugins: [ 26 | 'dev-expression' 27 | ] 28 | } 29 | }, 30 | sassLoader 31 | ] 32 | }, 33 | 34 | resolve: { 35 | modules: [ 36 | path.resolve('src'), 37 | 'node_modules' 38 | ] 39 | }, 40 | 41 | externals: { 42 | 'react': { 43 | root: 'React', 44 | commonjs: 'react', 45 | commonjs2: 'react', 46 | amd: 'react' 47 | }, 48 | 'react-dom': { 49 | root: 'ReactDOM', 50 | commonjs2: 'react-dom', 51 | commonjs: 'react-dom', 52 | amd: 'react-dom' 53 | }, 54 | 'react-bootstrap': { 55 | root: 'ReactBootstrap', 56 | commonjs2: 'react-bootstrap', 57 | commonjs: 'react-bootstrap', 58 | amd: 'react-bootstrap' 59 | } 60 | } 61 | } 62 | } 63 | 64 | export default withOptions() 65 | -------------------------------------------------------------------------------- /demo/examples/Tags.js: -------------------------------------------------------------------------------- 1 | // import Autosuggest, { ItemAdapter } from 'react-bootstrap-autosuggest' 2 | 3 | const predefinedTags = [ 4 | { value: 'Good', img: 'https://cdnjs.cloudflare.com/ajax/libs/emojione/2.1.4/assets/png/1f607.png' }, 5 | { value: 'Evil', img: 'https://cdnjs.cloudflare.com/ajax/libs/emojione/2.1.4/assets/png/1f608.png' }, 6 | { value: 'Confused', img: 'https://cdnjs.cloudflare.com/ajax/libs/emojione/2.1.4/assets/png/1f615.png' }, 7 | { value: 'Ugly', img: 'https://cdnjs.cloudflare.com/ajax/libs/emojione/2.1.4/assets/png/1f4a9.png' } 8 | ] 9 | 10 | class TagAdapter extends ItemAdapter { 11 | newFromValue(value) { 12 | return { value } 13 | } 14 | renderSelected(item) { 15 | return
16 | {item.value} {item.img && } 17 |
18 | } 19 | renderSuggested(item) { 20 | return
21 | {item.img && } {item.value} 22 |
23 | } 24 | } 25 | TagAdapter.instance = new TagAdapter() 26 | 27 | return function render({ 28 | tags, allowDuplicates, datalistOnly, multiLine, bsSize, onChange, onClear }) { 29 | return 30 | Tags 31 | ×} 41 | showToggle={!multiLine} 42 | onChange={onChange} /> 43 | 44 | } 45 | -------------------------------------------------------------------------------- /webpack/demo.config.babel.js: -------------------------------------------------------------------------------- 1 | import HtmlWebpackPlugin from 'html-webpack-plugin' 2 | import path from 'path' 3 | import webpack from 'webpack' 4 | import { cmdLineOptions, getBaseConfig, jsLoader, cssLoader, sassLoader } from './base.config.babel' 5 | 6 | export function withOptions(options = cmdLineOptions) { 7 | const webpackDevServerAddress = `http://${options.host}:${options.port}` 8 | const entryFile = path.resolve('demo/demo.js') 9 | 10 | const baseConfig = getBaseConfig(options) 11 | const debugLoaders = [] 12 | const entryBundle = [] 13 | if (options.debug) { 14 | baseConfig.plugins.push(new webpack.HotModuleReplacementPlugin()) 15 | baseConfig.plugins.push(new webpack.NamedModulesPlugin()) 16 | baseConfig.plugins.push(new webpack.NoEmitOnErrorsPlugin()) 17 | entryBundle.push('react-hot-loader/patch'), 18 | entryBundle.push(`webpack-dev-server/client?${webpackDevServerAddress}`) 19 | entryBundle.push('webpack/hot/only-dev-server') 20 | } 21 | entryBundle.push(entryFile) 22 | 23 | return { 24 | ...baseConfig, 25 | 26 | devtool: options.debug ? 'source-map' : false, 27 | 28 | entry: { 29 | 'demo': entryBundle 30 | }, 31 | 32 | output: { 33 | filename: '[name].js', 34 | path: path.resolve('site'), 35 | publicPath: options.debug ? `${webpackDevServerAddress}/` : '/react-bootstrap-autosuggest/' 36 | }, 37 | 38 | devServer: { 39 | contentBase: path.resolve('demo'), 40 | host: options.host, 41 | port: options.port 42 | }, 43 | 44 | module: { 45 | loaders: [ 46 | ...debugLoaders, 47 | { ...jsLoader, exclude: /node_modules|examples/ }, 48 | cssLoader, 49 | sassLoader, 50 | { test: /\.eot$|\.ttf$|\.svg$|\.woff2?$/, loader: 'file-loader?name=[name].[ext]' } 51 | ] 52 | }, 53 | 54 | plugins: [ 55 | ...baseConfig.plugins, 56 | new HtmlWebpackPlugin({ 57 | title: 'react-bootstrap-autosuggest', 58 | template: 'demo/index.ejs' 59 | }) 60 | ], 61 | 62 | resolve: { 63 | alias: { 64 | 'react-bootstrap-autosuggest': 'Autosuggest.js' 65 | }, 66 | modules: [ 67 | path.resolve('demo'), 68 | path.resolve('src'), 69 | 'node_modules' 70 | ] 71 | } 72 | } 73 | } 74 | 75 | export default withOptions() 76 | -------------------------------------------------------------------------------- /demo/sections/BasicSection.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Alert } from 'react-bootstrap' 3 | import Autosuggest from 'react-bootstrap-autosuggest' 4 | import Anchor from './Anchor' 5 | import Playground from './Playground' 6 | const NamePrefix = require('raw-loader!../examples/NamePrefix').trim() 7 | 8 | const scope = { Autosuggest } 9 | 10 | export default function BasicSection() { 11 | return ( 12 |
13 |

Basic usage

14 |

15 | In its most basic usage, <Autosuggest> acts like an 16 | <input> combined with a 17 | <datalist> 18 | (introduced in HTML5 but not yet supported by all browsers). 19 | The user is free to type any value, but a drop-down menu is available 20 | that will suggest predefined options containing the input text. 21 |

22 |

23 | In this name prefix example, 24 | typing m will suggest completions of "Mr.", "Mrs.", or "Ms.", 25 | though you are still free to type less common prefixes like "Maj." or "Msgr.". 26 | Standard <input> attributes like placeholder are 27 | propagated to the underlying input element. 28 |

29 | Edit Me!} /> 31 | 32 | For instructions on installing and importing Autosuggest within your project, 33 | see the README. 34 | 35 |

36 | Note that just like a React input element, an Autosuggest without a value property is 37 | an uncontrolled component. 38 | It starts off with an empty value and immediately reflects any user input. 39 | To listen to updates on the value, use the onChange event, as 40 | is done with controlled components. 41 |

42 |
43 | ) 44 | } 45 | -------------------------------------------------------------------------------- /demo/sections/CodeMirror-prefold.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import CodeMirror from 'codemirror' 4 | 5 | CodeMirror.registerGlobalHelper('fold', 'comment', function(mode) { 6 | return (mode.blockCommentStart && mode.blockCommentEnd) || mode.lineComment 7 | }, foldRangeFinder) 8 | 9 | export function prefold(codeMirror: CodeMirror) { 10 | const lastLine = codeMirror.lastLine() 11 | for (let line = 0; line <= lastLine; ++line) { 12 | const lineText = codeMirror.getLine(line) 13 | let pos = lineText.indexOf('$fold-line$') 14 | if (pos >= 0) { 15 | const mode = codeMirror.getModeAt(CodeMirror.Pos(line, pos)) 16 | pos = findCommentStart(mode, lineText, pos) 17 | if (pos >= 0) { 18 | codeMirror.foldCode(CodeMirror.Pos(line, pos)) 19 | continue 20 | } 21 | } 22 | pos = lineText.indexOf('$fold-start$') 23 | if (pos >= 0) { 24 | const mode = codeMirror.getModeAt(CodeMirror.Pos(line, pos)) 25 | pos = findCommentStart(mode, lineText, pos) 26 | if (pos >= 0) { 27 | codeMirror.foldCode(CodeMirror.Pos(line, pos), foldRangeFinder) 28 | } 29 | } 30 | } 31 | } 32 | 33 | export function foldRangeFinder(codeMirror: CodeMirror, start: { line: number, ch: number }) { 34 | const mode = codeMirror.getModeAt(start) 35 | const lastLine = codeMirror.lastLine() 36 | let { line, ch } = start 37 | for (; line <= lastLine; ++line, ch = 0) { 38 | const lineText = codeMirror.getLine(line) 39 | let pos = lineText.indexOf('$fold-end$', ch) 40 | if (pos >= 0) { 41 | pos = findCommentEnd(mode, lineText, pos) 42 | return { from: start, to: { line, pos } } 43 | } 44 | } 45 | } 46 | 47 | function findCommentStart(mode: Object, str: string, fromIndex: number): number { 48 | let pos = -1 49 | if (fromIndex >= 0) { 50 | if (mode.lineComment) { 51 | pos = str.lastIndexOf(mode.lineComment, fromIndex) 52 | } 53 | if (pos < 0 && mode.blockCommentStart) { 54 | pos = str.lastIndexOf(mode.blockCommentStart, fromIndex) 55 | } 56 | } 57 | return pos 58 | } 59 | 60 | function findCommentEnd(mode: Object, str: string, fromIndex: number): number { 61 | let pos = -1 62 | if (mode.lineComment) { 63 | pos = str.lastIndexOf(mode.lineComment, fromIndex) 64 | } 65 | if (pos < 0 && mode.blockCommentEnd) { 66 | pos = str.indexOf(mode.blockCommentEnd, fromIndex) 67 | if (pos < 0) { 68 | pos = str.length 69 | } else { 70 | pos += 2 71 | } 72 | } else { 73 | pos = str.length 74 | } 75 | return pos 76 | } 77 | -------------------------------------------------------------------------------- /demo/sections/PlaygroundSection.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Anchor from './Anchor' 3 | 4 | export default function PlaygroundSection() { 5 | return ( 6 |
7 |

Appendix: Live code editor

8 |

9 | All of the examples on this page are dynamically transpiled and rendered from 10 | code you can edit within the page itself. The code viewers are instances of 11 | the CodeMirror text editor. Whenever the 12 | code changes, it is transformed from programmer-friendly ES2015/JSX to 13 | browser-friendly ES5 using the Babel transpiler 14 | configured with the es2015 and react presets and 15 | the transform-object-rest-spread plugin. The transpiled code is 16 | then rendered into the DOM by React in one of three ways: 17 |

18 |
    19 |
  • 20 | If the code appears to be a JSX snippet, which starts with < and ends 21 | with >, then it is evaluated as an expression and automatically rendered. 22 |
  • 23 |
  • 24 | Otherwise, the code is evaluated as arbitrary JavaScript statements. The final statement 25 | can optionally return a render function. 26 |
      27 |
    • 28 | If a function is returned, it is called whenever the React state of its 29 | container changes. It is expected to return a React element, which will be 30 | automatically rendered. The remainder of the code is not re-evaluated when the 31 | state changes, so it can be used to provide constants to the render function. 32 |
    • 33 |
    • 34 | If the evaluated code does not return a function, it is expected to render itself 35 | using ReactDOM.render and the provided mountNode. 36 | The code is executed each time the React state of its container changes. 37 |
    • 38 |
    39 |
  • 40 |
41 |

42 | In all cases, at least the following symbols are injected into the scope of the 43 | evaluated code: React, ReactDOM, mountNode, 44 | and Autosuggest. Depending on the section, additional symbols may 45 | be injected, such as React-Bootstrap components, React state, or callback functions. 46 |

47 |
48 | ) 49 | } 50 | -------------------------------------------------------------------------------- /demo/sections/ListAdapterSection.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { 3 | ControlLabel, 4 | FormControl, 5 | FormGroup 6 | } from 'react-bootstrap' 7 | import Autosuggest, { ListAdapter } from 'react-bootstrap-autosuggest' 8 | import Anchor from './Anchor' 9 | import Playground from './Playground' 10 | const Note = require('raw-loader!../examples/Note').trim() 11 | 12 | const scope = { 13 | Autosuggest, 14 | ListAdapter, 15 | ControlLabel, 16 | FormControl, 17 | FormGroup 18 | } 19 | 20 | export default function ListAdapterSection() { 21 | return ( 22 |
23 |

List adapters

24 |

25 | As shown in the previous example, the datalist property need not be 26 | an array. In addition to arrays, Autosuggest has built-in support for map-like objects 27 | and ECMAScript 2015 Map objects. 28 | Internally, an array of items is constructed from the datalist using a subclass 29 | of ListAdapter. 30 | Normally, the list adapter is selected automatically if the datalist is an array, 31 | object, or map, but it can be specified explicitly using the datalistAdapter property. 32 | The built-in list adapter types are: 33 |

34 |
    35 |
  • EmptyListAdapter (used when datalist == null)
  • 36 |
  • ArrayListAdapter (used when Array.isArray(datalist))
  • 37 |
  • MapListAdapter (used when datalist instanceof Map)
  • 38 |
  • ObjectListAdapter (used when typeof datalist === 'object' and 39 | the other conditions are false)
  • 40 |
41 |
Keyed list adapters
42 |

43 | As mentioned above, the datalist items for objects and 44 | maps are objects with key and value properties corresponding to the datalist object 45 | property names/values or the datalist map entry keys/values, respectively. 46 | (The name of the key property is specified by the datalist adapter and defaults 47 | to key; the name of the value property is specified by 48 | the itemValuePropName property and defaults to value.) 49 | If the value of the property/entry is already an object with a value property, 50 | that object is used as the basis of the item object. 51 | If it also has a key property equal to the property name/entry key, it is used as the 52 | item object directly (and thus retains object identity). Otherwise, the object is cloned 53 | and the key property is set. 54 |

55 | 60 |
61 | ) 62 | } 63 | -------------------------------------------------------------------------------- /demo/demo.js: -------------------------------------------------------------------------------- 1 | import 'es5-shim/es5-shim' 2 | import 'es5-shim/es5-sham' 3 | import 'babel-polyfill' 4 | 5 | import React from 'react' 6 | import { Nav, Navbar, NavItem } from 'react-bootstrap' 7 | import ReactDOM from 'react-dom' 8 | 9 | import BasicSection from './sections/BasicSection' 10 | import ReactBootstrapSection from './sections/ReactBootstrapSection' 11 | import NonStringSection from './sections/NonStringSection' 12 | import ItemAdapterSection from './sections/ItemAdapterSection' 13 | import ItemsAsValuesSection from './sections/ItemsAsValuesSection' 14 | import ListAdapterSection from './sections/ListAdapterSection' 15 | import MultipleSection from './sections/MultipleSection' 16 | import DynamicSection from './sections/DynamicSection' 17 | import PlaygroundSection from './sections/PlaygroundSection' 18 | 19 | import 'Autosuggest.scss' 20 | import 'demo.scss' 21 | 22 | ReactDOM.render( 23 |
24 | 25 | 26 | 27 | 28 |
29 |
30 | react bootstrap 31 |
32 | autosuggest 33 |
34 |
35 | A modern combo-box{' '} 36 |
37 | built for React{' '} 38 | and Bootstrap 39 |
40 |
41 |
42 | 43 | 49 | 50 |
51 |
52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |
62 |
63 | Licensed under the ISC license by AffiniPay 64 |  | GitHub 65 |  · Issues 66 |  · Releases 67 |
68 |
, 69 | document.getElementById('app') 70 | ) 71 | -------------------------------------------------------------------------------- /src/Suggestions.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import shallowEqual from 'fbjs/lib/shallowEqual' 4 | import PropTypes from 'prop-types' 5 | import React from 'react' 6 | import { Dropdown, MenuItem } from 'react-bootstrap' 7 | import ReactDOM from 'react-dom' 8 | import type { Node } from './types' 9 | 10 | type Props = { 11 | datalistMessage?: Node; 12 | filtered?: boolean; 13 | focusedIndex?: number, 14 | getItemKey: (item: any) => string | number | boolean; 15 | isSelectedItem: (item: any) => boolean; 16 | items: any[]; 17 | labelledBy?: string | number; 18 | onClose?: () => void; 19 | onDatalistMessageSelect?: () => void; 20 | onDisableFilter?: () => void; 21 | onSelect: (item: any) => void; 22 | open: boolean; 23 | renderItem: (item: any) => Node; 24 | } 25 | 26 | type State = {} 27 | 28 | export default class Suggestions extends React.Component { 29 | static propTypes = { 30 | datalistMessage: PropTypes.node, 31 | filtered: PropTypes.bool, 32 | focusedIndex: PropTypes.number, 33 | getItemKey: PropTypes.func.isRequired, 34 | isSelectedItem: PropTypes.func.isRequired, 35 | items: PropTypes.arrayOf(PropTypes.any).isRequired, 36 | labelledBy: PropTypes.oneOfType([ 37 | PropTypes.string, 38 | PropTypes.number 39 | ]), 40 | onClose: PropTypes.func, 41 | onDatalistMessageSelect: PropTypes.func, 42 | onDisableFilter: PropTypes.func, 43 | onSelect: PropTypes.func.isRequired, 44 | open: PropTypes.bool, 45 | renderItem: PropTypes.func.isRequired 46 | }; 47 | 48 | props: Props; 49 | state: State; 50 | 51 | shouldComponentUpdate(nextProps: Props): boolean { 52 | return !shallowEqual(this.props, nextProps) 53 | } 54 | 55 | isFocused(): boolean { 56 | const menuNode = ReactDOM.findDOMNode(this.refs.menu) 57 | return menuNode != null && document && menuNode.contains(document.activeElement) 58 | } 59 | 60 | focusFirst() { 61 | const { menu } = this.refs 62 | menu.focusNext() 63 | } 64 | 65 | render(): React.Element<*> { 66 | const { items, datalistMessage, onDatalistMessageSelect } = this.props 67 | return 72 | {items.map(this._renderItem, this)} 73 | {this.props.filtered && 74 | 75 | {items.length === 0 ? : null} 76 | 77 | } 78 | {datalistMessage && 81 | {datalistMessage} 82 | } 83 | 84 | } 85 | 86 | _renderItem(item: any, index: number): React.Element<*> { 87 | const active = this.props.isSelectedItem(item) 88 | return 95 | {this.props.renderItem(item)} 96 | 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration 2 | require('babel-register') 3 | 4 | var webpackConfig = require('./webpack/test.config.babel').default 5 | 6 | var isCI = process.env.CONTINUOUS_INTEGRATION === 'true' 7 | var runCoverage = process.env.COVERAGE === 'true' || isCI 8 | var devBrowser = process.env.PHANTOM ? 'PhantomJS' : 'Chrome' 9 | var browsers = [ isCI ? 'PhantomJS' : devBrowser ] 10 | 11 | var reporters = ['mocha'] 12 | if (runCoverage) { 13 | webpackConfig = require('./webpack/test-coverage.config.babel').default 14 | reporters.push('coverage') 15 | } 16 | 17 | module.exports = function(config) { 18 | config.set({ 19 | 20 | // base path that will be used to resolve all patterns (eg. files, exclude) 21 | basePath: '', 22 | 23 | // frameworks to use 24 | // available frameworks: https://npmjs.org/browse/keyword/karma-adapter 25 | frameworks: [ 26 | 'mocha', 27 | 'sinon-chai' 28 | ], 29 | 30 | // list of files / patterns to load in the browser 31 | files: [ 32 | 'test/**/*-test.js' 33 | ], 34 | 35 | // preprocess matching files before serving them to the browser 36 | // available preprocessors: https://npmjs.org/browse/keyword/karma-preprocessor 37 | preprocessors: { 38 | 'test/**/*-test.js': ['webpack', 'sourcemap'] 39 | }, 40 | 41 | // test results reporter to use 42 | // possible values: 'dots', 'progress' 43 | // available reporters: https://npmjs.org/browse/keyword/karma-reporter 44 | reporters, 45 | 46 | coverageReporter: { 47 | check: { 48 | global: { 49 | statements: 99, // relaxed due to 'class extends' not being covered 50 | branches: 100, 51 | functions: 100, 52 | lines: 100 53 | } 54 | }, 55 | reporters: [ 56 | { 57 | type: 'text-summary' 58 | }, 59 | { 60 | type: 'html', 61 | dir: 'coverage' 62 | } 63 | ] 64 | }, 65 | 66 | plugins: [ 67 | 'karma-*' 68 | ], 69 | 70 | // web server port 71 | port: 9876, 72 | 73 | // enable / disable colors in the output (reporters and logs) 74 | colors: true, 75 | 76 | // level of logging 77 | // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG 78 | logLevel: config.LOG_INFO, 79 | 80 | // enable / disable watching file and executing tests whenever any file changes 81 | autoWatch: true, 82 | 83 | // start these browsers 84 | // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher 85 | browsers, 86 | 87 | // Continuous Integration mode 88 | // if true, Karma captures browsers, runs the tests and exits 89 | singleRun: isCI, 90 | 91 | // Concurrency level 92 | // how many browser should be started simultaneous 93 | concurrency: Infinity, 94 | 95 | webpack: Object.assign({}, webpackConfig, { 96 | devtool: 'inline-source-map', 97 | externals: { 98 | 'cheerio': 'window', 99 | 'react/addons': 'react', 100 | 'react/lib/ExecutionEnvironment': 'react', 101 | 'react/lib/ReactContext': 'react', 102 | } 103 | }), 104 | 105 | webpackServer: { 106 | noInfo: true 107 | } 108 | }) 109 | } 110 | -------------------------------------------------------------------------------- /demo/sections/ReactBootstrapSection.js: -------------------------------------------------------------------------------- 1 | import shallowEqual from 'fbjs/lib/shallowEqual' 2 | import React from 'react' 3 | import { 4 | Alert, 5 | ControlLabel, 6 | FormControl, 7 | FormGroup, 8 | HelpBlock 9 | } from 'react-bootstrap' 10 | import Autosuggest from 'react-bootstrap-autosuggest' 11 | import Anchor from './Anchor' 12 | import Playground from './Playground' 13 | import SizeSelect from './SizeSelect' 14 | import ValidationSelect from './ValidationSelect' 15 | const Browser = require('raw-loader!../examples/Browser').trim() 16 | 17 | export default class ReactBootstrapSection extends React.Component { 18 | constructor(...args) { 19 | super(...args) 20 | this.state = { 21 | browser: '', 22 | bsSize: 'large', 23 | validationState: 'error' 24 | } 25 | this._onBrowserChange = this._onBrowserChange.bind(this) 26 | this._onBsSizeChange = this._onBsSizeChange.bind(this) 27 | this._onValidationChange = this._onValidationChange.bind(this) 28 | } 29 | 30 | shouldComponentUpdate(nextProps: Object, nextState: Object) { 31 | return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState) 32 | } 33 | 34 | render() { 35 | return ( 36 |
37 |

React-Bootstrap integration

38 |

39 | In addition to being built on React and Bootstrap directly, Autosuggest is designed 40 | to leverage React-Bootstrap components 41 | when building complete forms. 42 | This example demonstrates its support for input group sizing and for validation state styling and feedback icons 43 | inherited from the containing 44 | <FormGroup> component. 45 |

46 | 49 | 52 | 65 | 66 |

67 | The feedback icon size and position are based on the bsSize property 68 | of the containing FormGroup, not of the Autosuggest, so don't forget 69 | to set both properties to the same value. 70 |

71 |
72 |
73 | ) 74 | } 75 | 76 | // autobind 77 | _onBrowserChange(value) { 78 | this.setState({ browser: value }) 79 | } 80 | 81 | // autobind 82 | _onBsSizeChange(event) { 83 | this.setState({ bsSize: event.target.value || undefined }) 84 | } 85 | 86 | // autobind 87 | _onValidationChange(event) { 88 | this.setState({ validationState: event.target.value || undefined }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /demo/examples/StateProvince.js: -------------------------------------------------------------------------------- 1 | // import Autosuggest, { ItemAdapter } from 'react-bootstrap-autosuggest' 2 | 3 | const states = [ 4 | { abbr: 'AL', name: 'Alabama' }, 5 | // $fold-start$ 6 | { abbr: 'AK', name: 'Alaska' }, 7 | { abbr: 'AZ', name: 'Arizona' }, 8 | { abbr: 'AR', name: 'Arkansas' }, 9 | { abbr: 'CA', name: 'California' }, 10 | { abbr: 'CO', name: 'Colorado' }, 11 | { abbr: 'CT', name: 'Connecticut' }, 12 | { abbr: 'DE', name: 'Delaware' }, 13 | { abbr: 'FL', name: 'Florida' }, 14 | { abbr: 'GA', name: 'Georgia' }, 15 | { abbr: 'HI', name: 'Hawaii' }, 16 | { abbr: 'ID', name: 'Idaho' }, 17 | { abbr: 'IL', name: 'Illinois' }, 18 | { abbr: 'IN', name: 'Indiana' }, 19 | { abbr: 'IA', name: 'Iowa' }, 20 | { abbr: 'KS', name: 'Kansas' }, 21 | { abbr: 'KY', name: 'Kentucky' }, 22 | { abbr: 'LA', name: 'Louisiana' }, 23 | { abbr: 'ME', name: 'Maine' }, 24 | { abbr: 'MD', name: 'Maryland' }, 25 | { abbr: 'MA', name: 'Massachusetts' }, 26 | { abbr: 'MI', name: 'Michigan' }, 27 | { abbr: 'MN', name: 'Minnesota' }, 28 | { abbr: 'MS', name: 'Mississippi' }, 29 | { abbr: 'MO', name: 'Missouri' }, 30 | { abbr: 'MT', name: 'Montana' }, 31 | { abbr: 'NE', name: 'Nebraska' }, 32 | { abbr: 'NV', name: 'Nevada' }, 33 | { abbr: 'NH', name: 'New Hampshire' }, 34 | { abbr: 'NJ', name: 'New Jersey' }, 35 | { abbr: 'NM', name: 'New Mexico' }, 36 | { abbr: 'NY', name: 'New York' }, 37 | { abbr: 'NC', name: 'North Carolina' }, 38 | { abbr: 'ND', name: 'North Dakota' }, 39 | { abbr: 'OH', name: 'Ohio' }, 40 | { abbr: 'OK', name: 'Oklahoma' }, 41 | { abbr: 'OR', name: 'Oregon' }, 42 | { abbr: 'PA', name: 'Pennsylvania' }, 43 | { abbr: 'RI', name: 'Rhode Island' }, 44 | { abbr: 'SC', name: 'South Carolina' }, 45 | { abbr: 'SD', name: 'South Dakota' }, 46 | { abbr: 'TN', name: 'Tennessee' }, 47 | { abbr: 'TX', name: 'Texas' }, 48 | { abbr: 'UT', name: 'Utah' }, 49 | { abbr: 'VT', name: 'Vermont' }, 50 | { abbr: 'VA', name: 'Virginia' }, 51 | { abbr: 'WA', name: 'Washington' }, 52 | { abbr: 'WV', name: 'West Virginia' }, 53 | { abbr: 'WI', name: 'Wisconsin' }, 54 | // $fold-end$ 55 | { abbr: 'WY', name: 'Wyoming' } 56 | ].map((item, index) => ({ 57 | ...item, 58 | // keep in source order to optimize sort by avoiding string comparison 59 | sortKey: index, 60 | // pre-calculate case-folded text representations 61 | textReps: [item.name.toLowerCase(), item.abbr.toLowerCase()] 62 | })) 63 | 64 | class StateAdapter extends ItemAdapter { 65 | getTextRepresentations(item) { 66 | return item.textReps 67 | } 68 | renderItem(item) { 69 | return
70 | {item.name}{item.abbr} 71 |
72 |
73 | } 74 | } 75 | StateAdapter.instance = new StateAdapter() 76 | 77 | return function render({ stateItem, stateValue, onChange, onSelect }) { 78 | return 79 |
80 | 81 | {stateItem && stateItem.abbr && `US state code: ${stateItem.abbr}`} 82 | 83 |
84 | State/province 85 | 94 |
95 | } 96 | -------------------------------------------------------------------------------- /demo/examples/GithubRepo.js: -------------------------------------------------------------------------------- 1 | // import Autosuggest, { ItemAdapter } from 'react-bootstrap-autosuggest' 2 | 3 | class RepoAdapter extends ItemAdapter { 4 | itemIncludedByInput() { 5 | return true // don't perform client filtering; show all server items 6 | } 7 | sortItems(items) { 8 | return items // don't sort items; just use server ordering 9 | } 10 | renderItem(item) { 11 | return
12 |
13 | 14 |
15 |
16 |
{item.full_name}
17 |
{item.description}
18 |
19 |
{item.watchers_count} Watchers
20 |
{item.stargazers_count} Stars
21 |
{item.forks_count} Forks
22 |
23 |
24 |
25 | } 26 | } 27 | RepoAdapter.instance = new RepoAdapter() 28 | 29 | let lastSearch 30 | 31 | function onRepoSearch(search, page, prev) { // $fold-line$ 32 | if (search) { 33 | // GitHub search doesn't allow slashes, so strip off user prefix 34 | const sp = search.lastIndexOf('/') 35 | if (sp >= 0) { 36 | search = search.substring(sp + 1) 37 | } 38 | 39 | // ignore redundant searches where only the user prefix changed 40 | if (search === lastSearch && !page) { 41 | return 42 | } 43 | lastSearch = search 44 | 45 | setState({ 46 | reposMessage: 'Searching for matching repositories...', 47 | reposMore: null 48 | }) 49 | let url = 'https://api.github.com/search/repositories?q=' + 50 | encodeURIComponent(search) 51 | if (page) { 52 | url += '&page=' + page 53 | } 54 | fetch(url).then(response => { 55 | if (response.ok) { 56 | response.json().then(json => { 57 | let repos, reposMessage, reposMore 58 | if (json.total_count === 0) { 59 | reposMessage = 'No matching repositories' 60 | } else { 61 | repos = prev ? prev.concat(json.items) : json.items 62 | if (repos.length < json.total_count) { 63 | reposMessage = 'Load more...' 64 | reposMore = () => onRepoSearch(search, page ? page + 1 : 2, repos) 65 | } 66 | } 67 | setState({ 68 | repos, 69 | reposMessage, 70 | reposMore 71 | }) 72 | }) 73 | } else { 74 | setState({ 75 | repos: null, 76 | reposMessage: 'Repository search returned error: ' + response.statusText, 77 | reposMore: null 78 | }) 79 | } 80 | }, err => { 81 | setState({ 82 | repos: null, 83 | reposMessage: 'Repository search failed: ' + err.message, 84 | reposMore: null 85 | }) 86 | }) 87 | } else { 88 | setState({ 89 | repos: null, 90 | reposMessage: 'Type at least one character to get suggestions', 91 | reposMore: null 92 | }) 93 | } 94 | } 95 | 96 | function onRepoChange(value) { 97 | setState({ repo: value }) 98 | } 99 | 100 | return function render({ state }) { 101 | return 102 | Repository 103 | 115 | 116 | } 117 | -------------------------------------------------------------------------------- /demo/sections/DynamicSection.js: -------------------------------------------------------------------------------- 1 | import shallowEqual from 'fbjs/lib/shallowEqual' 2 | import React from 'react' 3 | import { 4 | ControlLabel, 5 | FormControl, 6 | FormGroup, 7 | Glyphicon 8 | } from 'react-bootstrap' 9 | import Autosuggest, { ItemAdapter } from 'react-bootstrap-autosuggest' 10 | import Anchor from './Anchor' 11 | import Playground from './Playground' 12 | const GithubRepo = require('raw-loader!../examples/GithubRepo').trim() 13 | 14 | export default class DynamicSection extends React.Component { 15 | constructor(...args) { 16 | super(...args) 17 | this.state = { 18 | repo: '', 19 | repos: null, 20 | reposMessage: null, 21 | reposMore: null 22 | } 23 | this.setState = this.setState.bind(this) 24 | } 25 | 26 | shouldComponentUpdate(nextProps: Object, nextState: Object) { 27 | return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState) 28 | } 29 | 30 | render() { 31 | return ( 32 |
33 |

Dynamic datalists

34 |

35 | All of the preceding examples use static datalists, which do not change 36 | while the component is mounted. However, changing the datalist at any time 37 | is fully supported. Furthermore, there are several features specifically 38 | intended to support dynamic datalists. 39 |

40 |
onSearch event
41 |

42 | Autosuggest provides the onSearch event to allow the application 43 | to update the datalist based on user input. It is called periodically as the 44 | input value changes. The searchDebounce property, expressed in 45 | milliseconds, controls how frequently the onSearch event may be 46 | fired. 47 |

48 |
Datalist message
49 |

50 | The datalistMessage property, when defined, is a message displayed at the 51 | end of the drop-down menu of suggestions. It can be used for several purposes, such as 52 | indicating that the datalist is being fetched asynchronously, that an error occurred 53 | fetching the datalist, or that more suggestions are available to be fetched. 54 | If the onDatalistMessageSelect property is also defined, it causes the 55 | datalist message to become selectable and specifies a callback function to be 56 | invoked when it is selected. This feature is particularly useful for fetching 57 | additional suggestions. 58 |

59 |
Partial datalists
60 |

61 | By default, Autosuggest assumes that the datalist represents the entire set of valid 62 | options when datalistOnly is true. However, a common use for dynamic 63 | datalists is fetching a subset of a very large collection of options from a server. 64 | In these cases, the datalistPartial boolean property should be set. 65 | It causes Autosuggest to consider a value not in the datalist valid if either of 66 | the following are true: 67 |

68 |
    69 |
  • The value came from the value or defaultValue property.
  • 70 |
  • The value was selected from a previous datalist.
  • 71 |
72 | 86 |
87 | ) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-bootstrap-autosuggest", 3 | "version": "0.5.0", 4 | "description": "Autosuggest component for react-bootstrap", 5 | "main": "lib/Autosuggest.js", 6 | "files": [ 7 | "dist", 8 | "lib", 9 | "src" 10 | ], 11 | "scripts": { 12 | "build": "gulp", 13 | "clean": "gulp clean", 14 | "demo": "webpack-dev-server --config webpack/demo.config.babel.js -d --progress --inline", 15 | "flow": "gulp flow", 16 | "gh-pages": "gulp site && gh-pages -d site", 17 | "lint": "gulp lint", 18 | "prepublish": "gulp", 19 | "release": "gulp && release", 20 | "site": "gulp site", 21 | "test": "cross-env COVERAGE=true gulp test", 22 | "test-watch": "cross-env COVERAGE=true karma start" 23 | }, 24 | "author": "Trevor Robinson ", 25 | "repository": { 26 | "type": "git", 27 | "url": "https://github.com/affinipay/react-bootstrap-autosuggest.git" 28 | }, 29 | "keywords": [ 30 | "react", 31 | "bootstrap", 32 | "combobox", 33 | "datalist", 34 | "autocomplete", 35 | "autosuggest" 36 | ], 37 | "license": "ISC", 38 | "peerDependencies": { 39 | "react": "^15.5.0", 40 | "react-bootstrap": "^0.31.0", 41 | "react-dom": "^15.5.0" 42 | }, 43 | "dependencies": { 44 | "classnames": "^2.2.5", 45 | "fbjs": "^0.8.12", 46 | "keycode": "^2.1.8", 47 | "prop-types": "^15.5.8", 48 | "warning": "^3.0.0" 49 | }, 50 | "devDependencies": { 51 | "babel-cli": "^6.24.1", 52 | "babel-eslint": "^7.2.2", 53 | "babel-istanbul-loader": "^0.1.0", 54 | "babel-loader": "^6.4.1", 55 | "babel-plugin-dev-expression": "^0.2.1", 56 | "babel-plugin-transform-class-properties": "^6.24.1", 57 | "babel-plugin-transform-es3-member-expression-literals": "^6.22.0", 58 | "babel-plugin-transform-es3-property-literals": "^6.22.0", 59 | "babel-plugin-transform-object-rest-spread": "^6.23.0", 60 | "babel-polyfill": "^6.23.0", 61 | "babel-preset-es2015": "^6.24.1", 62 | "babel-preset-react": "^6.24.1", 63 | "babel-register": "^6.24.1", 64 | "babel-standalone": "^6.24.0", 65 | "bootstrap-sass": "^3.3.7", 66 | "chai": "^3.5.0", 67 | "chai-enzyme": "^0.6.1", 68 | "cross-env": "^5.2.0", 69 | "css-loader": "^0.28.0", 70 | "del": "^2.2.2", 71 | "diacritics": "^1.3.0", 72 | "enzyme": "^2.8.2", 73 | "es5-shim": "^4.5.9", 74 | "eslint": "^3.19.0", 75 | "eslint-loader": "^1.7.1", 76 | "eslint-plugin-mocha": "^4.9.0", 77 | "eslint-plugin-react": "^6.10.3", 78 | "file-loader": "^0.11.1", 79 | "flow-bin": "^0.44.2", 80 | "gh-pages": "^0.12.0", 81 | "gulp": "^3.9.1", 82 | "gulp-babel": "^6.1.2", 83 | "gulp-eslint": "^3.0.1", 84 | "gulp-react-docs": "^0.1.2", 85 | "gulp-replace": "^0.5.4", 86 | "html-webpack-plugin": "^2.28.0", 87 | "json-loader": "^0.5.4", 88 | "karma": "^1.6.0", 89 | "karma-chrome-launcher": "^2.0.0", 90 | "karma-coverage": "^1.1.1", 91 | "karma-mocha": "^1.3.0", 92 | "karma-mocha-reporter": "^2.2.3", 93 | "karma-phantomjs-launcher": "^1.0.4", 94 | "karma-sinon-chai": "^1.3.1", 95 | "karma-sourcemap-loader": "^0.3.7", 96 | "karma-webpack": "^2.0.3", 97 | "lolex": "^1.6.0", 98 | "mocha": "^3.2.0", 99 | "node-sass": "^4.5.2", 100 | "phantomjs-prebuilt": "^2.1.14", 101 | "raw-loader": "^0.5.1", 102 | "react": "^15.5.4", 103 | "react-addons-test-utils": "^15.5.1", 104 | "react-bootstrap": "^0.31.0", 105 | "react-codemirror": "^0.3.0", 106 | "react-docgen": "^2.14.0", 107 | "react-dom": "^15.5.4", 108 | "react-hot-loader": "^3.0.0-beta.6", 109 | "react-test-renderer": "^15.5.4", 110 | "release-script": "^1.0.2", 111 | "sass-loader": "^6.0.3", 112 | "sinon": "^2.1.0", 113 | "sinon-chai": "^2.9.0", 114 | "style-loader": "^0.16.1", 115 | "webpack": "^2.4.1", 116 | "webpack-dev-server": "^2.4.2", 117 | "webpack-stream": "^3.2.0", 118 | "yargs": "^7.1.0" 119 | }, 120 | "release-script": { 121 | "defaultDryRun": "true" 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-bootstrap-autosuggest 2 | 3 | `react-bootstrap-autosuggest` is a [ReactJS](https://facebook.github.io/react/) component that provides a [combo-box](https://en.wikipedia.org/wiki/Combo_box) input control styled using [Bootstrap](http://getbootstrap.com/). It is both inspired by and depends upon [`react-bootstrap`](https://react-bootstrap.github.io/). 4 | 5 | See the [live demo](https://affinipay.github.io/react-bootstrap-autosuggest/) on the home page. 6 | 7 | ## Getting started 8 | 9 | Install `react-bootstrap-autosuggest` using npm: 10 | 11 | npm install react-bootstrap-autosuggest --save 12 | 13 | Import the CommonJS module (which has been transpiled to ES3-compatible form using [Babel](https://babeljs.io/)): 14 | 15 | import Autosuggest from 'react-bootstrap-autosuggest' 16 | 17 | Alternatively, load the minified UMD (Universal Module Definition) build: 18 | 19 | 20 | 21 | Note that the CSS styles required by the component are no longer included automatically as of version 0.5. For [Webpack](https://webpack.github.io/) users, the recommended approach is to configure [sass-loader](https://github.com/jtangelder/sass-loader) and then `require('Autosuggest.scss')` in your application. You can either specify the full path (e.g. `./node_modules/react-bootstrap-autosuggest/src/Autosuggest.scss`) or include `./node_modules/react-bootstrap-autosuggest/src` as a search path. 22 | 23 | ## Motivation 24 | 25 | There are many auto-complete / auto-suggest / combo-box / enhanced-select input controls out there. However, I could not find any that met all of my requirements: 26 | 27 | * **True combo-box**: Combines a drop-down list and a single-line editable text box (not just a search box). The final input value need not come from a list of options (though optionally it may be required to). The user is free to enter any value, and the developer need not employ workarounds (like continually adding the current input value to the list of options) to achieve this. 28 | * **React**: Available as a ReactJS component that does not depend on other frameworks, such as jQuery. 29 | * **Bootstrap**: Supports full Bootstrap styling (including input group add-ons and sizing, validation states, and feedback icons). 30 | * **Dynamic**: Supports dynamically loading suggested values based on the current input value. 31 | * **Multi-select**: Supports selecting multiple values. 32 | * **Accessible**: Provides keyboard accessibility and compatibility with assistive technologies. 33 | * Production ready and actively maintained. 34 | 35 | ## Supported browsers 36 | 37 | `react-bootstrap-autosuggest` aims to support all modern desktop and mobile browsers. Despite some incomplete work to support IE 8, only IE 9+ are expected to work. If you find a browser-specific problem, please [report](https://github.com/affinipay/react-bootstrap-autosuggest/issues/new) it along with any code necessary to reproduce it. For visual/layout issues, please [attach](https://help.github.com/articles/file-attachments-on-issues-and-pull-requests/) an image of the issue. 38 | 39 | ## Support 40 | 41 | Please use [GitHub issues](https://github.com/affinipay/react-bootstrap-autosuggest/issues) for bug reports or feature requests. For usage questions, please use [Stack Overflow](http://stackoverflow.com/) to [ask a question with the `rbs-autosuggest` tag](http://stackoverflow.com/questions/ask?tags=rbs-autosuggest). 42 | 43 | ## Contributions 44 | 45 | Contributions in the form of [GitHub pull requests](https://github.com/affinipay/react-bootstrap-autosuggest/pulls) are welcome. Please adhere to the following guidelines: 46 | 47 | * Before embarking on a significant change, please create an issue to discuss the proposed change and ensure that it is likely to be merged. 48 | * Follow the coding conventions used throughout the project, including 2-space indentation and no unnecessary semicolons. Many conventions are enforced using `eslint`. 49 | * Include unit tests for any new code. This project maintains 100% code coverage. 50 | * Any contributions must be licensed under the ISC license. 51 | 52 | ## License 53 | 54 | `react-bootstrap-autosuggest` is available under the [ISC license](LICENSE). 55 | -------------------------------------------------------------------------------- /demo/sections/ItemAdapterSection.js: -------------------------------------------------------------------------------- 1 | import shallowEqual from 'fbjs/lib/shallowEqual' 2 | import React from 'react' 3 | import { 4 | ControlLabel, 5 | FormControl, 6 | FormGroup 7 | } from 'react-bootstrap' 8 | import Autosuggest, { ItemAdapter } from 'react-bootstrap-autosuggest' 9 | import Anchor from './Anchor' 10 | import Playground from './Playground' 11 | const StateProvince = require('raw-loader!../examples/StateProvince').trim() 12 | 13 | export default class ItemAdapterSection extends React.Component { 14 | constructor(...args) { 15 | super(...args) 16 | this.state = { 17 | stateValue: '', 18 | stateItem: null 19 | } 20 | this._onStateChange = this._onStateChange.bind(this) 21 | this._onStateSelect = this._onStateSelect.bind(this) 22 | } 23 | 24 | shouldComponentUpdate(nextProps: Object, nextState: Object) { 25 | return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState) 26 | } 27 | 28 | render() { 29 | return ( 30 |
31 |

Item adapters

32 |

33 | As mentioned in the previous example, the datalist property 34 | can contain arbitrary objects. By default, Autosuggest looks for a property 35 | called value on each item and displays value.toString() in 36 | the drop-down menu and (when an item is selected) in the input element. 37 | If no value property is defined, toString() is called on the 38 | item itself. The name of the value property can be overridden using 39 | the itemValuePropName property of the Autosuggest. 40 |

41 |

42 | Similarly, the key property of the object is used as the React 43 | element key in the drop-down menu, and the sortKey property is 44 | used for sorting the menu items. These property names can be overridden using 45 | the itemReactKeyPropName and itemSortKeyPropName properties, 46 | respectively. If these properties are not defined on a given object, the 47 | value property is used instead (after being converted to a string, unless 48 | it is a number). 49 |

50 |

51 | If a finer degree of control is desired, the itemAdapter property 52 | can be set to an application-provided subclass of 53 | the ItemAdapter class. 54 | In the example below, in addition to overriding the value and React key property 55 | names, an item adapter is provided to allow multiple text representations (state 56 | abbreviation as well as name) and to customize the drop-down menu item rendering 57 | (including US state images). 58 |

59 |

60 | Finally, this example also shows that while onChange is (generally) 61 | called with the input value, onSelect is called with datalist items. 62 | This feature allows the application to receive additional item information, such 63 | as an ID or database key, that is not present in the input value. In this example, 64 | if a US state is selected, its postal code is shown. 65 |

66 | 81 |
82 | ) 83 | } 84 | 85 | // autobind 86 | _onStateChange(value) { 87 | this.setState({ stateValue: value }) 88 | } 89 | 90 | // autobind 91 | _onStateSelect(item) { 92 | this.setState({ stateItem: item }) 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /demo/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/ItemAdapter.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import type { Node } from './types' 4 | 5 | export type Props = { 6 | itemReactKeyPropName?: string; 7 | itemSortKeyPropName?: string; 8 | itemValuePropName?: string; 9 | } 10 | 11 | function toStringOrNumber(v: any): string | number { 12 | return typeof v === 'number' ? v : v.toString() 13 | } 14 | 15 | export default class ItemAdapter { 16 | props: Props; 17 | 18 | receiveProps(props: Props) { 19 | this.props = props 20 | } 21 | 22 | getReactKey(item: I): string | number { 23 | const { itemReactKeyPropName: propName } = this.props 24 | if (propName) { 25 | const value = (item: any)[propName] 26 | if (value != null) { 27 | return value 28 | } 29 | } 30 | return toStringOrNumber(this.getRawValue(item)) 31 | } 32 | 33 | getSortKey(item: I): string | number { 34 | const { itemSortKeyPropName: propName } = this.props 35 | if (propName) { 36 | const value = (item: any)[propName] 37 | if (value != null) { 38 | return value 39 | } 40 | } 41 | return toStringOrNumber(this.getRawValue(item)) 42 | } 43 | 44 | getInputValue(item: I): string { 45 | return this.getRawValue(item).toString() 46 | } 47 | 48 | // protected 49 | getRawValue(item: I): any { 50 | const { itemValuePropName: propName } = this.props 51 | if (propName) { 52 | const value = (item: any)[propName] 53 | if (value != null) { 54 | return value 55 | } 56 | } 57 | return item 58 | } 59 | 60 | getTextRepresentations(item: I): string[] { 61 | return [this.foldValue(this.getInputValue(item))] 62 | } 63 | 64 | foldValue(value: string): string { 65 | // perform case folding by default; override for diacritic folding, etc. 66 | return value.toLowerCase() 67 | } 68 | 69 | newFromValue(value: string): string | Object { 70 | return value 71 | } 72 | 73 | itemIncludedByInput(item: I, foldedValue: string): boolean { 74 | for (let text of this.getTextRepresentations(item)) { 75 | if (text.indexOf(foldedValue) >= 0) { 76 | return true 77 | } 78 | } 79 | return false 80 | } 81 | 82 | itemMatchesInput(item: I, foldedValue: string): boolean { 83 | for (let text of this.getTextRepresentations(item)) { 84 | if (text === foldedValue) { 85 | return true 86 | } 87 | } 88 | return false 89 | } 90 | 91 | sortItems(items: I[], foldedValue: string): I[] { 92 | items.sort((a, b) => this.compareItemsWithValue(a, b, foldedValue)) 93 | return items 94 | } 95 | 96 | // protected 97 | compareItemsWithValue(a: I, b: I, foldedValue: string): number { 98 | // sort matching item(s) before non-matching 99 | const aMatches = this.itemMatchesInput(a, foldedValue) 100 | const bMatches = this.itemMatchesInput(b, foldedValue) 101 | if (aMatches != bMatches) { 102 | return aMatches ? -1 : 1 103 | } 104 | // then sort based on inclusion rank 105 | const aRank = this.itemInclusionRankForInput(a, foldedValue) 106 | const bRank = this.itemInclusionRankForInput(b, foldedValue) 107 | if (aRank != bRank) { 108 | return aRank - bRank 109 | } 110 | // within same inclusion rank, compare items ignoring value 111 | return this.compareItems(a, b) 112 | } 113 | 114 | // protected 115 | itemInclusionRankForInput(item: I, foldedValue: string): number { 116 | let contains = false 117 | for (let text of this.getTextRepresentations(item)) { 118 | const index = text.indexOf(foldedValue) 119 | if (index === 0) { 120 | return 0 121 | } 122 | if (index > 0) { 123 | contains = true 124 | } 125 | } 126 | return contains ? 1 : 2 127 | } 128 | 129 | // protected 130 | compareItems(a: I, b: I): number { 131 | const aSortKey: any = this.getSortKey(a) 132 | const bSortKey: any = this.getSortKey(b) 133 | return aSortKey < bSortKey ? -1 : aSortKey == bSortKey ? 0 : 1 134 | } 135 | 136 | // protected 137 | renderItem(item: I): Node { // default rendering for both dropdown and multiple 138 | return this.getInputValue(item) 139 | } 140 | 141 | renderSuggested(item: I): Node { // dropdown rendering 142 | return this.renderItem(item) 143 | } 144 | 145 | renderSelected(item: I): Node { // multiple selected rendering 146 | return this.renderItem(item) 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /demo/sections/ItemsAsValuesSection.js: -------------------------------------------------------------------------------- 1 | import { remove as removeDiacritics } from 'diacritics' 2 | import shallowEqual from 'fbjs/lib/shallowEqual' 3 | import React from 'react' 4 | import { 5 | ControlLabel, 6 | FormControl, 7 | FormGroup 8 | } from 'react-bootstrap' 9 | import Autosuggest, { ItemAdapter } from 'react-bootstrap-autosuggest' 10 | import Anchor from './Anchor' 11 | import Playground from './Playground' 12 | const Country = require('raw-loader!../examples/Country').trim() 13 | 14 | export default class ItemsAsValuesSection extends React.Component { 15 | constructor(...args) { 16 | super(...args) 17 | this.state = { 18 | country: null 19 | } 20 | this._onCountryChange = this._onCountryChange.bind(this) 21 | } 22 | 23 | shouldComponentUpdate(nextProps: Object, nextState: Object) { 24 | return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState) 25 | } 26 | 27 | render() { 28 | return ( 29 |
30 |

Items as values

31 |

32 | In the previous examples, the value supplied to the value property 33 | and received from the onChange event was the string value used in 34 | the input element. In cases where the datalist items are objects, it may be more 35 | natural to exchange item objects directly. Specifying the boolean property 36 | valueIsItem enables this mode of operation. 37 |

38 |
Datalist-only constraint
39 |

40 | A typical usage of this feature is object selection, where the object must be 41 | an item from the datalist. To enforce this constraint, the boolean 42 | property datalistOnly can also be specified. Not only does this 43 | option force the final value of the Autosuggest to come from the datalist, it 44 | also auto-completes the input value as soon as it matches only a single item. 45 | (Alternatively, if dynamic creation of objects based on the input value is 46 | desired, ItemAdapter.newFromValue() can be overridden to customize 47 | the object provided to the onChange and onSelect handlers. 48 | By default, the input value string itself is provided.) 49 |

50 |
Datalist as object
51 |

52 | This example also demonstrates the use of a datalist that is an object (as opposed 53 | to an array). In this case, the enumerable, owned properties of the object are 54 | used to derive the items of the datalist. By default, each item 55 | has key and value properties assigned to the name and 56 | value, respectively, of the corresponding property. As discussed above, the 57 | name of the value property can be changed using the itemValuePropName property 58 | of the Autosuggest (which this example does). The name of the key property is determined by 59 | the datalist adapter, where the default name key is 60 | designed to match the default value of the itemReactKeyPropName property. 61 |

62 |
Value folding
63 |

64 | Finally, this example demonstrates custom value folding by overriding ItemAdapter.foldValue(). 65 | By default, only case-folding is performed, which means that the input value and each 66 | item value are converted to lower-case before comparison. This example also 67 | performs diacritic-folding by 68 | converting each accented character to one or more base/ASCII characters. 69 | However, since diacritic-folding can be performed in many different ways, 70 | it is not built into the Autosuggest component. Note that the folded names 71 | are also used as sort keys, which avoids the need to use the relatively 72 | slow localeCompare function when sorting. 73 |

74 | 88 |
89 | ) 90 | } 91 | 92 | // autobind 93 | _onCountryChange(item) { 94 | this.setState({ country: item }) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /gulpfile.babel.js: -------------------------------------------------------------------------------- 1 | import child_process from 'child_process' 2 | import del from 'del' 3 | import flow from 'flow-bin' 4 | import gulp from 'gulp' 5 | import babel from 'gulp-babel' 6 | import eslint from 'gulp-eslint' 7 | import gulpReactDocs from 'gulp-react-docs' 8 | import gulpReplace from 'gulp-replace' 9 | import { Server as KarmaServer } from 'karma' 10 | import webpack from 'webpack' 11 | import webpackStream from 'webpack-stream' 12 | 13 | import { withOptions as demoWithOptions } from './webpack/demo.config.babel' 14 | import webpackConfig, { withOptions as webpackWithOptions } from './webpack/webpack.config.babel' 15 | 16 | const apidocs = './site/apidocs' 17 | const demo = './demo' 18 | const dist = './dist' 19 | const lib = './lib' 20 | const site = './site' 21 | const src = './src' 22 | const test = './test' 23 | 24 | if (!Object.values) { 25 | Object.values = obj => Object.keys(obj).map(key => obj[key]) 26 | } 27 | 28 | function getWebpackEntries(config) { 29 | const { entry } = config 30 | return (Array.isArray(entry) || typeof entry !== 'object') ? entry : Object.values(entry) 31 | } 32 | 33 | function execute(command, args, options, callback) { 34 | const child = child_process.spawn(command, args, options) 35 | child.stdout.on('data', data => process.stdout.write(data.toString())) 36 | child.stderr.on('data', data => process.stderr.write(data.toString())) 37 | child.on('close', code => { 38 | callback(code != 0 ? new Error(`${command} exited with code ${code}`) : null) 39 | }) 40 | } 41 | 42 | gulp.task('clean', function() { 43 | return del([dist, lib, site]) 44 | }) 45 | 46 | gulp.task('clean-dist', function() { 47 | return del([dist]) 48 | }) 49 | 50 | gulp.task('clean-lib', function() { 51 | return del([lib]) 52 | }) 53 | 54 | gulp.task('clean-site', function() { 55 | return del([site]) 56 | }) 57 | 58 | gulp.task('clean-apidocs', function() { 59 | return del([apidocs]) 60 | }) 61 | 62 | gulp.task('clean-demo', function() { 63 | return del([site, '!' + apidocs]) 64 | }) 65 | 66 | gulp.task('babel', ['clean-lib'], function() { 67 | return gulp.src(src + '/*.js') 68 | .pipe(babel()) 69 | .pipe(gulp.dest(lib)) 70 | }) 71 | 72 | gulp.task('webpack', ['clean-dist'], function() { 73 | return gulp.src(getWebpackEntries(webpackConfig)) 74 | .pipe(webpackStream(webpackConfig, webpack)) 75 | .pipe(gulp.dest(dist)) 76 | }) 77 | 78 | gulp.task('webpack-min', ['clean-dist'], function() { 79 | const baseConfig = webpackWithOptions({ 80 | optimizeMinimize: true 81 | }) 82 | const config = { 83 | ...baseConfig, 84 | bail: true, 85 | plugins: [ 86 | ...baseConfig.plugins, 87 | new webpack.optimize.UglifyJsPlugin() 88 | ] 89 | } 90 | return gulp.src(getWebpackEntries(config)) 91 | .pipe(webpackStream(config, webpack)) 92 | .pipe(gulp.dest(dist)) 93 | }) 94 | 95 | gulp.task('default', ['babel', 'webpack', 'webpack-min']) 96 | 97 | gulp.task('apidocs', ['clean-apidocs'], function() { 98 | return gulp.src('./src/Autosuggest.js') 99 | // react-docgen uses an old version of babylon 100 | // that doesn't support inferred type parameter syntax 101 | .pipe(gulpReplace(/<\*[^>]*>/g, '')) 102 | .pipe(gulpReactDocs({ 103 | path: apidocs 104 | })) 105 | .pipe(gulpReplace(''', '\'')) 106 | .pipe(gulpReplace( 107 | 'From [`../../src/Autosuggest.js`](../../src/Autosuggest.js)', 108 | 'From [`src/Autosuggest.js`](../../master/src/Autosuggest.js)')) 109 | .pipe(gulp.dest(apidocs)) 110 | }) 111 | 112 | gulp.task('demo-copy', ['clean-demo'], function() { 113 | return gulp.src([ 114 | demo + '/*.ico', 115 | demo + '/*.json', 116 | demo + '/*.png', 117 | demo + '/*.svg', 118 | demo + '/*.xml', 119 | demo + '/images/*' 120 | ], { base: demo }) 121 | .pipe(gulp.dest(site)) 122 | }) 123 | 124 | gulp.task('demo-webpack', ['clean-demo'], function() { 125 | const baseConfig = demoWithOptions({ 126 | optimizeMinimize: true 127 | }) 128 | const config = { 129 | ...baseConfig, 130 | bail: true, 131 | plugins: [ 132 | ...baseConfig.plugins, 133 | new webpack.optimize.UglifyJsPlugin() 134 | ] 135 | } 136 | const entries = getWebpackEntries(config) 137 | return gulp.src(entries[entries.length - 1]) 138 | .pipe(webpackStream(config, webpack)) 139 | .pipe(gulp.dest(site)) 140 | }) 141 | 142 | gulp.task('site', ['apidocs', 'demo-copy', 'demo-webpack']) 143 | 144 | gulp.task('lint', function() { 145 | return gulp.src([src, test, demo].map(s => s + '/**/*.js')) 146 | .pipe(eslint()) 147 | .pipe(eslint.format()) 148 | .pipe(eslint.failAfterError()) 149 | }) 150 | 151 | gulp.task('flow', function(callback) { 152 | execute(flow, ['--color', 'always'], undefined, callback) 153 | }) 154 | 155 | gulp.task('karma', function (callback) { 156 | new KarmaServer({ 157 | configFile: __dirname + '/karma.conf.js', 158 | singleRun: true 159 | }, callback).start() 160 | }) 161 | 162 | gulp.task('test', ['lint', 'flow', 'karma']) 163 | 164 | gulp.task('all', ['default', 'site', 'test']) 165 | -------------------------------------------------------------------------------- /demo/sections/MultipleSection.js: -------------------------------------------------------------------------------- 1 | import shallowEqual from 'fbjs/lib/shallowEqual' 2 | import React from 'react' 3 | import { 4 | Alert, 5 | Button, 6 | Checkbox, 7 | ControlLabel, 8 | FormControl, 9 | FormGroup 10 | } from 'react-bootstrap' 11 | import Autosuggest, { ItemAdapter } from 'react-bootstrap-autosuggest' 12 | import Anchor from './Anchor' 13 | import Playground from './Playground' 14 | import SizeSelect from './SizeSelect' 15 | const Tags = require('raw-loader!../examples/Tags').trim() 16 | 17 | export default class MultipleSection extends React.Component { 18 | constructor(...args) { 19 | super(...args) 20 | this.state = { 21 | tags: [{ value: 'Good' }, { value: 'Bad' }, { value: 'Ugly' }], 22 | allowDuplicates: false, 23 | datalistOnly: false, 24 | multiLine: false, 25 | bsSize: undefined 26 | } 27 | this._toggleDatalistOnly = this._toggleDatalistOnly.bind(this) 28 | this._toggleAllowDuplicates = this._toggleAllowDuplicates.bind(this) 29 | this._toggleMultiLine = this._toggleMultiLine.bind(this) 30 | this._onBsSizeChange = this._onBsSizeChange.bind(this) 31 | this._onTagsChange = this._onTagsChange.bind(this) 32 | this._onTagsClear = this._onTagsClear.bind(this) 33 | } 34 | 35 | shouldComponentUpdate(nextProps: Object, nextState: Object) { 36 | return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState) 37 | } 38 | 39 | render() { 40 | return ( 41 |
42 |

Multiple selections

43 |

44 | Autosuggest supports selection of multiple options using a tag/pill style of 45 | user interface. The allowDuplicates boolean property (which defaults 46 | to false) controls whether an option can be selected more than once. 47 | Unless datalistOnly is enabled, arbitrary values can be selected by 48 | pressing Enter after typing them. 49 |

50 |

51 | The application can control the rendering of both the drop-down menu options and the selected 52 | items by overriding the renderSuggested and renderSelected methods, 53 | respectively, of the ItemAdapter. By default, both of these methods call 54 | the renderItem method, which can be overridden instead to use the same 55 | rendering in both contexts. 56 |

57 |

58 | Normally, the Autosuggest component maintains a fixed height (like other Bootstrap 59 | input elements). However, if the drop-down menu toggle is disabled and no input 60 | group add-ons are specified, the height of the component is allowed to grow as 61 | necessary to contain the selections. 62 |

63 | 66 | Allow duplicates 67 | 68 | 71 | Datalist-only 72 | 73 | 76 | Multi-line (hides drop-down toggle and clear button) 77 | 78 | 81 | 100 | 101 | Due to Bootstrap's use of absolute 102 | positioning for feedback icons, manual adjustment is necessary when using 103 | them in combination with add-ons or buttons at the end of the input group, 104 | such as the clear (×) button above. 105 | 106 |
107 | ) 108 | } 109 | 110 | // autobind 111 | _toggleDatalistOnly() { 112 | this.setState({ datalistOnly: !this.state.datalistOnly }) 113 | } 114 | 115 | // autobind 116 | _toggleAllowDuplicates() { 117 | this.setState({ allowDuplicates: !this.state.allowDuplicates }) 118 | } 119 | 120 | // autobind 121 | _toggleMultiLine() { 122 | this.setState({ multiLine: !this.state.multiLine }) 123 | } 124 | 125 | // autobind 126 | _onBsSizeChange(event) { 127 | this.setState({ bsSize: event.target.value || undefined }) 128 | } 129 | 130 | // autobind 131 | _onTagsChange(items) { 132 | this.setState({ tags: items }) 133 | } 134 | 135 | // autobind 136 | _onTagsClear() { 137 | this.setState({ tags: [] }) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /demo/demo.scss: -------------------------------------------------------------------------------- 1 | @import "bootstrap/variables"; 2 | $icon-font-path: "../node_modules/bootstrap-sass/assets/fonts/bootstrap/"; 3 | $grid-float-breakpoint: $screen-md-min; 4 | @import "bootstrap"; 5 | 6 | body { 7 | background-color: #f0f0f0; 8 | font-size: 14px; 9 | } 10 | 11 | .navbar-autosuggest .navbar-header { 12 | white-space: nowrap; 13 | .logo { 14 | height: 72px; 15 | margin: 2rem; 16 | vertical-align: top; 17 | } 18 | .titles { 19 | display: inline-block; 20 | margin: 2.5rem 0; 21 | white-space: normal; 22 | .title { 23 | font-size: 1.7em; 24 | font-weight: bold; 25 | .primary { 26 | color: #2daae1; 27 | } 28 | .secondary { 29 | color: #000; 30 | } 31 | } 32 | .subtitle { 33 | color: #444; 34 | margin: 0.5rem 0; 35 | a { 36 | color: inherit; 37 | text-decoration: underline; 38 | } 39 | } 40 | } 41 | } 42 | 43 | .navbar-autosuggest .navbar-nav { 44 | li { 45 | font-size: 1.2em; 46 | font-weight: bold; 47 | a { 48 | padding-bottom: 0.5em; 49 | } 50 | &.active > a, 51 | &.active > a:hover, 52 | &.active > a:focus { 53 | background-color: inherit; 54 | color: #2daae1; 55 | cursor: default; 56 | } 57 | } 58 | } 59 | 60 | @media (min-width: $screen-md-min) { 61 | .navbar-autosuggest .navbar-nav { 62 | float: right; 63 | margin-top: 3rem; 64 | li a:hover { 65 | border-bottom: 3px solid #2daae1; 66 | } 67 | } 68 | } 69 | 70 | .visible-xxs-inline { 71 | display: none; 72 | } 73 | 74 | @media (max-width: 500px) { 75 | .navbar-autosuggest .navbar-header { 76 | .logo { 77 | height: 48px; 78 | margin-top: 3rem; 79 | } 80 | .titles { 81 | margin: 1rem 0; 82 | .title { 83 | font-size: 1.5em; 84 | line-height: 1.1; 85 | } 86 | } 87 | } 88 | .visible-xxs-inline { 89 | display: inline; 90 | } 91 | } 92 | 93 | .container { 94 | margin: 2rem auto; 95 | max-width: 800px; 96 | } 97 | 98 | .footer { 99 | line-height: 30px; 100 | margin: 4rem 0; 101 | text-align: center; 102 | 103 | iframe { 104 | border: none; 105 | vertical-align: top; 106 | } 107 | } 108 | 109 | .playground { 110 | margin-bottom: 36px; 111 | 112 | .example { 113 | background-color: #e0e0e0; 114 | border: 1px solid #ccc; 115 | border-radius: 4px 4px 0 4px; 116 | padding-top: 30px; 117 | position: relative; 118 | 119 | &:after { 120 | color: #aaa; 121 | content: "Example"; 122 | font-size: 12px; 123 | font-weight: bold; 124 | left: 15px; 125 | letter-spacing: 1px; 126 | position: absolute; 127 | text-shadow: 1px 1px 1px #fff; 128 | text-transform: uppercase; 129 | top: 15px; 130 | } 131 | 132 | .mount-node { 133 | margin: 15px; 134 | } 135 | 136 | .editor { 137 | margin: 15px; 138 | overflow: hidden; 139 | position: relative; 140 | 141 | .CodeMirror { 142 | border: 1px solid #888; 143 | border-radius: 4px; 144 | height: auto; 145 | } 146 | 147 | .ribbon { 148 | background: #ec0; 149 | color: #a60; 150 | border: solid #fea; 151 | border-width: 1px 0; 152 | font-size: 12px; 153 | font-weight: bold; 154 | line-height: 1.5; 155 | position: absolute; 156 | right: -35px; 157 | text-align: center; 158 | text-shadow: 1px 1px 1px #fea; 159 | top: 15px; 160 | transform: rotate(45deg); 161 | width: 120px; 162 | z-index: 3; 163 | 164 | a { 165 | color: inherit; 166 | } 167 | } 168 | } 169 | 170 | .error-node { 171 | margin: 15px; 172 | 173 | .alert { 174 | clear: both; 175 | max-height: 200px; 176 | overflow: auto; 177 | 178 | pre { 179 | background-color: inherit; 180 | border: none; 181 | color: inherit; 182 | float: left; 183 | margin: 0; 184 | padding: 0; 185 | } 186 | } 187 | } 188 | } 189 | 190 | .code-tab { 191 | background: #e0e0e0; 192 | border: 1px solid #ccc; 193 | border-bottom-left-radius: 4px; 194 | border-bottom-right-radius: 4px; 195 | border-top: none; 196 | display: inline-block; 197 | float: right; 198 | margin-left: 8px; 199 | padding: 4px 8px; 200 | position: relative; 201 | top: -1px; 202 | 203 | &:hover, 204 | &:focus { 205 | text-decoration: none; 206 | } 207 | } 208 | } 209 | 210 | .anchor, 211 | .anchor:hover, 212 | .anchor:active, 213 | .anchor:focus { 214 | color: black; 215 | text-decoration: none; 216 | position: relative; 217 | } 218 | .anchor-icon { 219 | font-size: 90%; 220 | padding-top: 0.1em; 221 | position: absolute; 222 | left: -0.8em; 223 | opacity: 0; 224 | } 225 | 226 | h1:hover .anchor-icon, 227 | h1 a:focus .anchor-icon, 228 | h2:hover .anchor-icon, 229 | h2 a:focus .anchor-icon, 230 | h3:hover .anchor-icon, 231 | h3 a:focus .anchor-icon, 232 | h4:hover .anchor-icon, 233 | h4 a:focus .anchor-icon { 234 | opacity: 0.5; 235 | } 236 | 237 | .dropdown-menu { 238 | max-height: 20em; 239 | max-width: 100%; 240 | overflow-x: hidden; 241 | overflow-y: auto; 242 | } 243 | 244 | .state-item { 245 | clear: both; 246 | height: 41px; 247 | line-height: 41px; 248 | .state-image { 249 | float: right; 250 | } 251 | } 252 | 253 | .abbrev { 254 | font-size: 12px; 255 | opacity: 0.7; 256 | &:before { 257 | content: "\2003" 258 | } 259 | } 260 | 261 | // adjust position of feedback icon due to clear button 262 | #tagInput { 263 | .form-control-feedback { 264 | right: 34px; 265 | } 266 | .dropdown, .dropup { 267 | & + .form-control-feedback { 268 | right: 68px; 269 | } 270 | } 271 | } 272 | 273 | .tag img { 274 | height: 1em; 275 | } 276 | 277 | .repo { 278 | overflow-y: auto; 279 | } 280 | 281 | .repo-avatar { 282 | float: left; 283 | margin-right: 10px; 284 | padding: 3px 0; 285 | width: 60px; 286 | img { 287 | border-radius: 3px; 288 | height: auto; 289 | width: 100%; 290 | } 291 | } 292 | 293 | .repo-meta { 294 | margin-left: 70px; 295 | overflow-x: hidden; 296 | } 297 | 298 | .repo-title { 299 | color: black; 300 | font-weight: bold; 301 | overflow-x: hidden; 302 | text-overflow: ellipsis; 303 | } 304 | 305 | .repo-desc { 306 | color: #777; 307 | overflow-x: hidden; 308 | padding: 3px 0; 309 | text-overflow: ellipsis; 310 | } 311 | 312 | .repo-stats { 313 | color: #aaa; 314 | font-size: 11px; 315 | > div { 316 | display: inline-block; 317 | &:not(:first-child) { 318 | margin-left: 1em; 319 | } 320 | } 321 | } 322 | 323 | li.active { 324 | .repo-title { 325 | color: white; 326 | } 327 | .repo-desc, .repo-stats { 328 | color: #ccc; 329 | } 330 | } 331 | -------------------------------------------------------------------------------- /src/Choices.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import classNames from 'classnames' 4 | import shallowEqual from 'fbjs/lib/shallowEqual' 5 | import keycode from 'keycode' 6 | import PropTypes from 'prop-types' 7 | import React from 'react' 8 | import ReactDOM from 'react-dom' 9 | import type { Node } from './types' 10 | 11 | export default class Choices extends React.Component { 12 | static propTypes = { 13 | autoHeight: PropTypes.bool, 14 | disabled: PropTypes.bool, 15 | focused: PropTypes.bool, 16 | inputValue: PropTypes.string, 17 | items: PropTypes.arrayOf(PropTypes.any).isRequired, 18 | onKeyPress: PropTypes.func, 19 | onRemove: PropTypes.func, 20 | renderItem: PropTypes.func.isRequired 21 | }; 22 | 23 | props: { 24 | autoHeight?: boolean; 25 | children?: Node | Node[]; 26 | disabled?: boolean; 27 | focused?: boolean; 28 | inputValue: string; 29 | items: any[]; 30 | onKeyPress?: (event: SyntheticKeyboardEvent) => void; 31 | onRemove?: (index: number) => void; 32 | renderItem: (item: any) => Node; 33 | }; 34 | 35 | state: { 36 | }; 37 | 38 | constructor(...args: any) { 39 | super(...args) 40 | /* istanbul ignore next: https://github.com/gotwarlost/istanbul/issues/690#issuecomment-265718617 */ 41 | const self: any = this // https://github.com/facebook/flow/issues/1517 42 | self._handleKeyDown = this._handleKeyDown.bind(this) 43 | self._handleKeyPress = this._handleKeyPress.bind(this) 44 | self._handleClose = this._handleClose.bind(this) 45 | self._handleClick = this._handleClick.bind(this) 46 | self._focusInput = this._focusInput.bind(this) 47 | } 48 | 49 | shouldComponentUpdate(nextProps: Object) { 50 | return !shallowEqual(this.props, nextProps) 51 | } 52 | 53 | render() { 54 | const { autoHeight, disabled, focused, inputValue, items, renderItem, children } = this.props 55 | const hasItems = items.length > 0 56 | let inputStyle 57 | if (hasItems) { 58 | // guesstimate input width since inline-block container 59 | // won't allow it to expand automatically 60 | inputStyle = { width: ((inputValue.length + 1) * 0.75) + 'em' } 61 | } 62 | return ( 63 |
    71 | {items.map((item, index) => 72 |
  • 77 | 79 | 81 | {renderItem(item)} 82 | 83 |
  • )} 84 |
  • 85 | {children} 86 |
  • 87 |
88 | ) 89 | } 90 | 91 | // autobind 92 | _handleKeyDown(event: SyntheticKeyboardEvent) { 93 | switch (event.keyCode) { 94 | case keycode.codes.left: 95 | this._focusPrevious() 96 | event.preventDefault() 97 | break 98 | case keycode.codes.right: 99 | this._focusNext() 100 | event.preventDefault() 101 | break 102 | case keycode.codes.backspace: 103 | this._removeActive(-1) 104 | event.preventDefault() 105 | break 106 | case keycode.codes.delete: 107 | this._removeActive(0) 108 | event.preventDefault() 109 | break 110 | } 111 | } 112 | 113 | // autobind 114 | _handleKeyPress(event: SyntheticKeyboardEvent) { 115 | // Chrome and Safari lets the input accept the key, Firefox does not 116 | this._focusInput() 117 | 118 | const { onKeyPress } = this.props 119 | // istanbul ignore else 120 | if (onKeyPress) { 121 | onKeyPress(event) 122 | } 123 | } 124 | 125 | // autobind 126 | _handleClose(event: SyntheticEvent) { 127 | if (!this.props.disabled && event.target instanceof HTMLElement) { 128 | const choices = event.target.parentNode 129 | // istanbul ignore else 130 | if (choices instanceof Element) { 131 | const index = Number(choices.getAttribute('data-index')) 132 | this._remove(index) 133 | } 134 | } 135 | event.stopPropagation() 136 | } 137 | 138 | // autobind 139 | _handleClick(event: SyntheticEvent) { 140 | event.stopPropagation() 141 | } 142 | 143 | focusFirst() { 144 | const items = this._getFocusableMenuItems(false) 145 | if (items.length > 0) { 146 | items[0].focus() 147 | } 148 | } 149 | 150 | focusLast() { 151 | const items = this._getFocusableMenuItems(false) 152 | if (items.length > 0) { 153 | items[items.length - 1].focus() 154 | } 155 | } 156 | 157 | _focusPrevious() { 158 | const { items, activeIndex } = this._getItemsAndActiveIndex(true) 159 | // istanbul ignore else: currently input handles wrap-around 160 | if (activeIndex > 0) { 161 | items[activeIndex - 1].focus() 162 | } else if (items.length > 0) { 163 | items[items.length - 1].focus() 164 | } 165 | } 166 | 167 | _focusNext() { 168 | const { items, activeIndex } = this._getItemsAndActiveIndex(true) 169 | // istanbul ignore else: currently input handles wrap-around 170 | if (activeIndex < items.length - 1) { 171 | items[activeIndex + 1].focus() 172 | } else if (items.length > 0) { 173 | items[0].focus() 174 | } 175 | } 176 | 177 | // autobind 178 | _focusInput() { 179 | const node = ReactDOM.findDOMNode(this) 180 | // istanbul ignore else 181 | if (node instanceof Element) { 182 | const input = node.querySelector('input') 183 | // istanbul ignore else 184 | if (input) { 185 | input.focus() 186 | } 187 | } 188 | } 189 | 190 | _remove(index: number) { 191 | const { onRemove } = this.props 192 | // istanbul ignore else 193 | if (onRemove) { 194 | onRemove(index) 195 | } 196 | } 197 | 198 | _removeActive(focusAdjust: number) { 199 | const { items, activeIndex } = this._getItemsAndActiveIndex(false) 200 | // istanbul ignore else 201 | if (activeIndex >= 0) { 202 | let nextIndex = activeIndex + focusAdjust 203 | if (nextIndex < 0 || nextIndex >= items.length - 1) { 204 | this._focusInput() 205 | } else if (focusAdjust != 0) { 206 | items[nextIndex].focus() 207 | } 208 | this._remove(activeIndex) 209 | } 210 | } 211 | 212 | _getItemsAndActiveIndex(includeInput: boolean): { items: HTMLElement[], activeIndex: number } { 213 | const items = this._getFocusableMenuItems(includeInput) 214 | const activeElement = document.activeElement 215 | const activeIndex = activeElement ? items.indexOf(activeElement) : // istanbul ignore next 216 | -1 217 | return { items, activeIndex } 218 | } 219 | 220 | _getFocusableMenuItems(includeInput: boolean): HTMLElement[] { 221 | const node = ReactDOM.findDOMNode(this) 222 | // istanbul ignore else 223 | if (node instanceof Element) { 224 | return Array.from(node.querySelectorAll( 225 | includeInput ? '[tabIndex="-1"],input' : '[tabIndex="-1"]')) 226 | } else { 227 | return [] 228 | } 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /demo/sections/Playground.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { transform } from 'babel-standalone' 4 | import shallowEqual from 'fbjs/lib/shallowEqual' 5 | import PropTypes from 'prop-types' 6 | import React from 'react' 7 | import { Alert, SafeAnchor } from 'react-bootstrap' 8 | import ReactCodeMirror from 'react-codemirror' 9 | import ReactDOM from 'react-dom' 10 | 11 | import CodeMirror from 'codemirror' 12 | import 'codemirror/mode/jsx/jsx' 13 | import 'codemirror/addon/fold/foldcode' 14 | import 'codemirror/addon/fold/foldgutter' 15 | import 'codemirror/addon/fold/brace-fold' 16 | 17 | import { prefold } from './CodeMirror-prefold' 18 | 19 | import 'codemirror/lib/codemirror.css' 20 | import 'codemirror/theme/material.css' 21 | import 'codemirror/addon/fold/foldgutter.css' 22 | 23 | type Props = { 24 | code: string, 25 | codeFolding?: boolean, 26 | lineNumbers?: boolean, 27 | ribbonText?: string, 28 | scope: Object, 29 | showCode?: boolean 30 | } 31 | 32 | type State = { 33 | code: string, 34 | showCode: boolean 35 | } 36 | 37 | type UserFn = (scope: Object) => ?React.Element<*> 38 | 39 | export default class Playground extends React.Component { 40 | static propTypes = { 41 | code: PropTypes.string.isRequired, 42 | codeFolding: PropTypes.bool, 43 | lineNumbers: PropTypes.bool, 44 | ribbonText: PropTypes.node, 45 | scope: PropTypes.object.isRequired, 46 | showCode: PropTypes.bool 47 | }; 48 | 49 | state: State; 50 | 51 | _cachedUserFn: ?UserFn; 52 | 53 | constructor(props: Props, ...args: any) { 54 | super(props, ...args) 55 | this.state = { 56 | code: this.props.code, 57 | showCode: this.props.showCode || false 58 | } 59 | const self: any = this 60 | self._handleCodeMount = this._handleCodeMount.bind(this) 61 | self._handleCodeChange = this._handleCodeChange.bind(this) 62 | self._handleCodeReset = this._handleCodeReset.bind(this) 63 | self._handleCodeToggle = this._handleCodeToggle.bind(this) 64 | } 65 | 66 | componentDidMount() { 67 | this._transformAndExecuteCode(this.state.code) 68 | } 69 | 70 | shouldComponentUpdate(nextProps: Props, nextState: State): boolean { 71 | return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState) 72 | } 73 | 74 | componentWillUpdate(nextProps: Props, nextState: State) { 75 | if (nextState.code !== this.state.code) { 76 | this._transformAndExecuteCode(nextState.code) 77 | } else if (nextProps.scope !== this.props.scope) { 78 | this._renderCode(this._buildScope(nextProps)) 79 | } 80 | } 81 | 82 | componentWillUnmount() { 83 | const { mountNode, errorNode } = this.refs 84 | ReactDOM.unmountComponentAtNode(errorNode) 85 | ReactDOM.unmountComponentAtNode(mountNode) 86 | } 87 | 88 | render() { 89 | return ( 90 |
91 | {this._renderExample()} 92 | {this._renderToggle()} 93 | {this._renderReset()} 94 |
95 | ) 96 | } 97 | 98 | _renderExample() { 99 | return ( 100 |
101 |
102 | {this._renderEditor()} 103 |
104 |
105 | ) 106 | } 107 | 108 | _renderEditor() { 109 | if (!this.state.showCode) { 110 | return null 111 | } 112 | const options: Object = { 113 | extraKeys: { 114 | 'Tab': false, // let focus go to next control 115 | 'Shift-Tab': false, // let focus go to previous control 116 | 'Alt-Tab': 'indentMore', 117 | 'Shift-Alt-Tab': 'indentLess' 118 | }, 119 | foldGutter: this.props.codeFolding, 120 | gutters: [], 121 | lineNumbers: this.props.lineNumbers, 122 | lineWrapping: false, 123 | matchBrackets: true, 124 | mode: 'jsx', 125 | readOnly: false, 126 | smartIndent: true, 127 | theme: 'material' 128 | } 129 | if (this.props.lineNumbers) { 130 | options.gutters.push('CodeMirror-linenumbers') 131 | } 132 | if (this.props.codeFolding) { 133 | options.gutters.push('CodeMirror-foldgutter') 134 | } 135 | const { ribbonText } = this.props 136 | return
137 | {ribbonText &&
{ribbonText}
} 138 | 145 |
146 | } 147 | 148 | _renderReset() { 149 | return this.state.code !== this.props.code ? ( 150 | 151 | reset code 152 | 153 | ) : null 154 | } 155 | 156 | _renderToggle() { 157 | return ( 158 | 159 | {this.state.showCode ? 'hide code' : 'show code'} 160 | 161 | ) 162 | } 163 | 164 | // autobind 165 | _handleCodeMount(ref: ReactCodeMirror) { 166 | if (ref && this.props.codeFolding) { 167 | prefold(ref.getCodeMirror()) 168 | } 169 | } 170 | 171 | // autobind 172 | _handleCodeChange(value: string) { 173 | this.setState({ code: value }) 174 | } 175 | 176 | // autobind 177 | _handleCodeReset() { 178 | this.setState({ code: this.props.code }) 179 | } 180 | 181 | // autobind 182 | _handleCodeToggle() { 183 | this.setState({ showCode: !this.state.showCode }) 184 | } 185 | 186 | _transformAndExecuteCode(code: string) { 187 | const { errorNode } = this.refs 188 | let transformedCode = null 189 | try { 190 | const scope = this._buildScope(this.props) 191 | const scopeKeys = Object.keys(scope).join(',') 192 | const isJSX = this._isJSX(code) 193 | let wrapperCode 194 | if (isJSX) { 195 | wrapperCode = `(({ ${scopeKeys} }) => ( ${code} ))` 196 | } else { 197 | wrapperCode = `(({ ${scopeKeys} }) => { ${code} })` 198 | } 199 | this._cachedUserFn = null 200 | transformedCode = transform(wrapperCode, 201 | { 202 | presets: ['es2015', 'react'], 203 | plugins: ['transform-object-rest-spread'] 204 | }).code 205 | this._cachedUserFn = eval(transformedCode) 206 | let result = this._cachedUserFn(scope) 207 | // allow non-JSX to return a render function, to avoid recomputing constants 208 | // on each state change, which may cause unnecessary component re-rendering 209 | if (!isJSX && typeof result === 'function') { 210 | this._cachedUserFn = result 211 | result = this._cachedUserFn(scope) 212 | } 213 | if (result != null) { 214 | ReactDOM.render(result, this.refs.mountNode) 215 | } 216 | ReactDOM.unmountComponentAtNode(errorNode) 217 | } catch (err) { 218 | ReactDOM.unmountComponentAtNode(this.refs.mountNode) 219 | ReactDOM.render( 220 | 221 |
{err.toString()}
222 | {transformedCode &&
{transformedCode}
} 223 |
, 224 | errorNode 225 | ) 226 | } 227 | } 228 | 229 | _renderCode(scope: Object) { 230 | const { _cachedUserFn } = this 231 | if (_cachedUserFn != null) { 232 | const result = _cachedUserFn(scope) 233 | if (result != null) { 234 | ReactDOM.render(result, this.refs.mountNode) 235 | } 236 | } 237 | } 238 | 239 | _buildScope(props: Props): Object { 240 | const { mountNode } = this.refs 241 | return { ...props.scope, React, ReactDOM, mountNode } 242 | } 243 | 244 | _isJSX(code: string) { 245 | const trimmed = code.trim() 246 | return trimmed.startsWith('<') && trimmed.endsWith('>') 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/Autosuggest.scss: -------------------------------------------------------------------------------- 1 | @import "bootstrap/variables"; 2 | @import "bootstrap/mixins"; 3 | 4 | $autosuggest-dropdown-toggle-width-base: ($padding-base-horizontal * 2 + $caret-width-base * 2 + 2) !default; 5 | $autosuggest-dropdown-toggle-width-small: ($padding-small-horizontal * 2 + $caret-width-base * 2 + 2) !default; 6 | $autosuggest-dropdown-toggle-width-large: ($padding-large-horizontal * 2 + $caret-width-large * 2 + 2) !default; 7 | $autosuggest-choice-bg-color: #eee; 8 | $autosuggest-choice-border-color: #aaa; 9 | $autosuggest-choice-border-radius: $border-radius-small; 10 | $autosuggest-choice-color: $input-color; 11 | $autosuggest-choice-close-bg-color: $autosuggest-choice-bg-color; 12 | $autosuggest-choice-close-bg-hover-color: #ddd; 13 | $autosuggest-choice-close-color: $autosuggest-choice-border-color; 14 | $autosuggest-choice-close-hover-color: #000; 15 | $autosuggest-choice-gutter-base: 5px; 16 | $autosuggest-choice-gutter-small: 4px; 17 | $autosuggest-choice-gutter-large: 8px; 18 | 19 | @mixin form-control-feedback-size($input-height, $font-size) { 20 | font-size: $font-size; 21 | height: $input-height; 22 | line-height: $input-height; 23 | width: $input-height; 24 | } 25 | 26 | // adjust position of feedback icon due to dropdown toggle 27 | .autosuggest { 28 | &.dropdown + .form-control-feedback, 29 | &.dropup + .form-control-feedback { 30 | right: $autosuggest-dropdown-toggle-width-base; 31 | } 32 | } 33 | .form-group-sm .autosuggest { 34 | & + .form-control-feedback { 35 | @include form-control-feedback-size($input-height-small, $font-size-small) 36 | } 37 | &.dropdown + .form-control-feedback, 38 | &.dropup + .form-control-feedback { 39 | right: $autosuggest-dropdown-toggle-width-small; 40 | } 41 | } 42 | .form-group-lg .autosuggest { 43 | & + .form-control-feedback { 44 | @include form-control-feedback-size($input-height-large, $font-size-large) 45 | } 46 | &.dropdown + .form-control-feedback, 47 | &.dropup + .form-control-feedback { 48 | right: $autosuggest-dropdown-toggle-width-large; 49 | } 50 | } 51 | 52 | // fix input group button height to match input box (bootstrap-sass bug?) 53 | .autosuggest .input-group-btn .btn { 54 | height: $input-height-base; 55 | } 56 | 57 | // extend Bootstrap form validation styles to input group buttons, including dropdown toggles 58 | .has-success .input-group-btn .btn { 59 | color: $state-success-text; 60 | border-color: $state-success-text; 61 | background-color: $state-success-bg; 62 | } 63 | 64 | .has-warning .input-group-btn .btn { 65 | color: $state-warning-text; 66 | border-color: $state-warning-text; 67 | background-color: $state-warning-bg; 68 | } 69 | 70 | .has-error .input-group-btn .btn { 71 | color: $state-danger-text; 72 | border-color: $state-danger-text; 73 | background-color: $state-danger-bg; 74 | } 75 | 76 | .autosuggest .dropdown-menu { 77 | // ensure dropdown menu is at least as wide as the input group 78 | min-width: 100%; 79 | 80 | // indicate using hover style that pressing enter while input is focused and 81 | // dropdown menu is open will select the first item 82 | li.pseudofocused > a { 83 | color: $dropdown-link-hover-color; 84 | background-color: $dropdown-link-hover-bg; 85 | } 86 | 87 | // use CSS for localization of show-all/no-matches text 88 | .show-all { 89 | font-style: italic; 90 | } 91 | .show-all:before { 92 | content: "Show all" 93 | } 94 | .no-matches:after { 95 | content: " (no matches)" 96 | } 97 | .show-all:lang(de):before { 98 | content: "Zeige alles" 99 | } 100 | .no-matches:lang(de):after { 101 | content: " (keine Treffer)" 102 | } 103 | .show-all:lang(es):before { 104 | content: "Mostrar todo" 105 | } 106 | .no-matches:lang(es):after { 107 | content: " (no hay coincidencias)" 108 | } 109 | .show-all:lang(fr):before { 110 | content: "Montre tout" 111 | } 112 | .no-matches:lang(fr):after { 113 | content: " (pas de correspondance)" 114 | } 115 | } 116 | 117 | .autosuggest-choices { 118 | cursor: text; 119 | overflow: hidden; 120 | li { 121 | // allow li/input to fill container if !has-items 122 | display: block; 123 | } 124 | &.focused { 125 | $color: $input-border-focus; 126 | $color-rgba: rgba(red($color), green($color), blue($color), .6); 127 | border-color: $color; 128 | outline: 0; 129 | @include box-shadow(inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px $color-rgba); 130 | z-index: 3 !important; 131 | } 132 | &.has-items { 133 | padding: 0 !important; 134 | li { 135 | display: inline-block; 136 | margin-left: $autosuggest-choice-gutter-base; 137 | margin-top: $autosuggest-choice-gutter-base; 138 | } 139 | li:nth-last-child(2) { 140 | margin-bottom: $autosuggest-choice-gutter-base; 141 | } 142 | input { 143 | vertical-align: middle; 144 | } 145 | &.auto-height { 146 | height: auto; 147 | } 148 | } 149 | } 150 | 151 | .input-group-sm .autosuggest-choices.has-items { 152 | li { 153 | margin-left: $autosuggest-choice-gutter-small; 154 | margin-top: $autosuggest-choice-gutter-small; 155 | } 156 | li:nth-last-child(2) { 157 | margin-bottom: $autosuggest-choice-gutter-small; 158 | } 159 | } 160 | 161 | .input-group-lg .autosuggest-choices.has-items { 162 | li { 163 | margin-left: $autosuggest-choice-gutter-large; 164 | margin-top: $autosuggest-choice-gutter-large; 165 | } 166 | li:nth-last-child(2) { 167 | margin-bottom: $autosuggest-choice-gutter-large; 168 | } 169 | } 170 | 171 | .autosuggest-choice { 172 | background-color: $autosuggest-choice-bg-color; 173 | border: 1px solid $autosuggest-choice-border-color; 174 | border-radius: $autosuggest-choice-border-radius; 175 | cursor: default; 176 | line-height: normal; 177 | padding: 0; 178 | vertical-align: top; 179 | white-space: nowrap; 180 | &:focus { 181 | $color: $input-border-focus; 182 | $color-rgba: rgba(red($color), green($color), blue($color), .6); 183 | border-color: $color; 184 | outline: 0; 185 | @include box-shadow(inset 0 1px 1px rgba(0,0,0,.075), 0 0 8px $color-rgba); 186 | } 187 | } 188 | 189 | .autosuggest-choice-close { 190 | border-right: 1px solid $autosuggest-choice-border-color; 191 | color: $autosuggest-choice-border-color; 192 | cursor: pointer; 193 | display: inline-block; 194 | padding: 1px 4px 3px 5px; 195 | &:before { 196 | content: "\00d7 "; 197 | } 198 | .autosuggest-choices:not([disabled]) &:hover { 199 | background-color: $autosuggest-choice-close-bg-hover-color; 200 | color: $autosuggest-choice-close-hover-color; 201 | } 202 | .autosuggest-choices[disabled] & { 203 | cursor: $cursor-disabled; 204 | } 205 | } 206 | 207 | .autosuggest-choice-label { 208 | color: $autosuggest-choice-color; 209 | display: inline-block; 210 | padding: 1px 5px 3px; 211 | } 212 | 213 | .autosuggest-input-choice { 214 | overflow: hidden; 215 | white-space: nowrap; 216 | input { 217 | @include placeholder; 218 | border: none; 219 | padding: 0; 220 | width: 100%; 221 | &:focus { 222 | outline: none; 223 | } 224 | &[disabled], 225 | &[readonly], 226 | fieldset[disabled] & { 227 | background-color: $input-bg-disabled; 228 | opacity: 1; 229 | } 230 | &[disabled], 231 | fieldset[disabled] & { 232 | cursor: $cursor-disabled; 233 | } 234 | } 235 | } 236 | 237 | @mixin autosuggest-choices-validation($text-color: #555, $border-color: #ccc, $background-color: #f5f5f5) { 238 | &.focused { 239 | border-color: darken($border-color, 10%); 240 | $shadow: inset 0 1px 1px rgba(0,0,0,.075), 0 0 6px lighten($border-color, 20%); 241 | @include box-shadow($shadow); 242 | } 243 | } 244 | 245 | .has-success .autosuggest-choices { 246 | @include autosuggest-choices-validation($state-success-text, $state-success-text, $state-success-bg); 247 | } 248 | .has-warning .autosuggest-choices { 249 | @include autosuggest-choices-validation($state-warning-text, $state-warning-text, $state-warning-bg); 250 | } 251 | .has-error .autosuggest-choices { 252 | @include autosuggest-choices-validation($state-danger-text, $state-danger-text, $state-danger-bg); 253 | } 254 | -------------------------------------------------------------------------------- /demo/examples/Country.js: -------------------------------------------------------------------------------- 1 | // import Autosuggest, { ItemAdapter } from 'react-bootstrap-autosuggest' 2 | // import { remove as removeDiacritics } from 'diacritics' 3 | 4 | const countries = { 5 | 'AD': { name: 'Andorra' }, 6 | // $fold-start$ 7 | 'AE': { name: 'United Arab Emirates' }, 8 | 'AF': { name: 'Afghanistan' }, 9 | 'AG': { name: 'Antigua and Barbuda' }, 10 | 'AI': { name: 'Anguilla' }, 11 | 'AL': { name: 'Albania' }, 12 | 'AM': { name: 'Armenia' }, 13 | 'AO': { name: 'Angola' }, 14 | 'AQ': { name: 'Antarctica' }, 15 | 'AR': { name: 'Argentina' }, 16 | 'AS': { name: 'American Samoa' }, 17 | 'AT': { name: 'Austria' }, 18 | 'AU': { name: 'Australia' }, 19 | 'AW': { name: 'Aruba' }, 20 | 'AX': { name: 'Åland Islands' }, 21 | 'AZ': { name: 'Azerbaijan' }, 22 | 'BA': { name: 'Bosnia and Herzegovina' }, 23 | 'BB': { name: 'Barbados' }, 24 | 'BD': { name: 'Bangladesh' }, 25 | 'BE': { name: 'Belgium' }, 26 | 'BF': { name: 'Burkina Faso' }, 27 | 'BG': { name: 'Bulgaria' }, 28 | 'BH': { name: 'Bahrain' }, 29 | 'BI': { name: 'Burundi' }, 30 | 'BJ': { name: 'Benin' }, 31 | 'BL': { name: 'Saint-Barthélemy' }, 32 | 'BM': { name: 'Bermuda' }, 33 | 'BN': { name: 'Brunei Darussalam' }, 34 | 'BO': { name: 'Bolivia' }, 35 | 'BQ': { name: 'Bonaire, Sint Eustatius and Saba' }, 36 | 'BR': { name: 'Brazil' }, 37 | 'BS': { name: 'Bahamas' }, 38 | 'BT': { name: 'Bhutan' }, 39 | 'BV': { name: 'Bouvet Island' }, 40 | 'BW': { name: 'Botswana' }, 41 | 'BY': { name: 'Belarus' }, 42 | 'BZ': { name: 'Belize' }, 43 | 'CA': { name: 'Canada' }, 44 | 'CC': { name: 'Cocos (Keeling) Islands' }, 45 | 'CD': { name: 'Congo (Democratic Republic of the)' }, 46 | 'CF': { name: 'Central African Republic' }, 47 | 'CG': { name: 'Congo' }, 48 | 'CH': { name: 'Switzerland' }, 49 | 'CI': { name: 'Côte d\'Ivoire' }, 50 | 'CK': { name: 'Cook Islands' }, 51 | 'CL': { name: 'Chile' }, 52 | 'CM': { name: 'Cameroon' }, 53 | 'CN': { name: 'China' }, 54 | 'CO': { name: 'Colombia' }, 55 | 'CR': { name: 'Costa Rica' }, 56 | 'CU': { name: 'Cuba' }, 57 | 'CV': { name: 'Cape Verde' }, 58 | 'CW': { name: 'Curaçao' }, 59 | 'CX': { name: 'Christmas Island' }, 60 | 'CY': { name: 'Cyprus' }, 61 | 'CZ': { name: 'Czech Republic' }, 62 | 'DE': { name: 'Germany' }, 63 | 'DJ': { name: 'Djibouti' }, 64 | 'DK': { name: 'Denmark' }, 65 | 'DM': { name: 'Dominica' }, 66 | 'DO': { name: 'Dominican Republic' }, 67 | 'DZ': { name: 'Algeria' }, 68 | 'EC': { name: 'Ecuador' }, 69 | 'EE': { name: 'Estonia' }, 70 | 'EG': { name: 'Egypt' }, 71 | 'EH': { name: 'Western Sahara' }, 72 | 'ER': { name: 'Eritrea' }, 73 | 'ES': { name: 'Spain' }, 74 | 'ET': { name: 'Ethiopia' }, 75 | 'FI': { name: 'Finland' }, 76 | 'FJ': { name: 'Fiji' }, 77 | 'FK': { name: 'Falkland Islands (Malvinas)' }, 78 | 'FM': { name: 'Federated States of Micronesia' }, 79 | 'FO': { name: 'Faeroe Islands' }, 80 | 'FR': { name: 'France' }, 81 | 'GA': { name: 'Gabon' }, 82 | 'GB': { name: 'United Kingdom' }, 83 | 'GD': { name: 'Grenada' }, 84 | 'GE': { name: 'Georgia' }, 85 | 'GF': { name: 'French Guiana' }, 86 | 'GG': { name: 'Guernsey' }, 87 | 'GH': { name: 'Ghana' }, 88 | 'GI': { name: 'Gibraltar' }, 89 | 'GL': { name: 'Greenland' }, 90 | 'GM': { name: 'Gambia' }, 91 | 'GN': { name: 'Guinea' }, 92 | 'GP': { name: 'Guadeloupe' }, 93 | 'GQ': { name: 'Equatorial Guinea' }, 94 | 'GR': { name: 'Greece' }, 95 | 'GS': { name: 'South Georgia and the South Sandwich Islands' }, 96 | 'GT': { name: 'Guatemala' }, 97 | 'GU': { name: 'Guam' }, 98 | 'GW': { name: 'Guinea-Bissau' }, 99 | 'GY': { name: 'Guyana' }, 100 | 'HK': { name: 'Hong Kong' }, 101 | 'HM': { name: 'Heard Island and McDonald Islands' }, 102 | 'HN': { name: 'Honduras' }, 103 | 'HR': { name: 'Croatia' }, 104 | 'HT': { name: 'Haiti' }, 105 | 'HU': { name: 'Hungary' }, 106 | 'ID': { name: 'Indonesia' }, 107 | 'IE': { name: 'Ireland' }, 108 | 'IL': { name: 'Israel' }, 109 | 'IM': { name: 'Isle of Man' }, 110 | 'IN': { name: 'India' }, 111 | 'IO': { name: 'British Indian Ocean Territory' }, 112 | 'IQ': { name: 'Iraq' }, 113 | 'IR': { name: 'Iran' }, 114 | 'IS': { name: 'Iceland' }, 115 | 'IT': { name: 'Italy' }, 116 | 'JE': { name: 'Jersey' }, 117 | 'JM': { name: 'Jamaica' }, 118 | 'JO': { name: 'Jordan' }, 119 | 'JP': { name: 'Japan' }, 120 | 'KE': { name: 'Kenya' }, 121 | 'KG': { name: 'Kyrgyzstan' }, 122 | 'KH': { name: 'Cambodia' }, 123 | 'KI': { name: 'Kiribati' }, 124 | 'KM': { name: 'Comoros' }, 125 | 'KN': { name: 'Saint Kitts and Nevis' }, 126 | 'KP': { name: 'Korea (Democratic People\'s Republic of)' }, 127 | 'KR': { name: 'Korea (Republic of)' }, 128 | 'KW': { name: 'Kuwait' }, 129 | 'KY': { name: 'Cayman Islands' }, 130 | 'KZ': { name: 'Kazakhstan' }, 131 | 'LA': { name: 'Lao People\'s Democratic Republic' }, 132 | 'LB': { name: 'Lebanon' }, 133 | 'LC': { name: 'Saint Lucia' }, 134 | 'LI': { name: 'Liechtenstein' }, 135 | 'LK': { name: 'Sri Lanka' }, 136 | 'LR': { name: 'Liberia' }, 137 | 'LS': { name: 'Lesotho' }, 138 | 'LT': { name: 'Lithuania' }, 139 | 'LU': { name: 'Luxembourg' }, 140 | 'LV': { name: 'Latvia' }, 141 | 'LY': { name: 'Libya' }, 142 | 'MA': { name: 'Morocco' }, 143 | 'MC': { name: 'Monaco' }, 144 | 'MD': { name: 'Moldova' }, 145 | 'ME': { name: 'Montenegro' }, 146 | 'MF': { name: 'Saint-Martin (French part)' }, 147 | 'MG': { name: 'Madagascar' }, 148 | 'MH': { name: 'Marshall Islands' }, 149 | 'MK': { name: 'Macedonia (The Former Yugoslav Republic of)' }, 150 | 'ML': { name: 'Mali' }, 151 | 'MM': { name: 'Myanmar' }, 152 | 'MN': { name: 'Mongolia' }, 153 | 'MO': { name: 'Macao' }, 154 | 'MP': { name: 'Northern Mariana Islands' }, 155 | 'MQ': { name: 'Martinique' }, 156 | 'MR': { name: 'Mauritania' }, 157 | 'MS': { name: 'Montserrat' }, 158 | 'MT': { name: 'Malta' }, 159 | 'MU': { name: 'Mauritius' }, 160 | 'MV': { name: 'Maldives' }, 161 | 'MW': { name: 'Malawi' }, 162 | 'MX': { name: 'Mexico' }, 163 | 'MY': { name: 'Malaysia' }, 164 | 'MZ': { name: 'Mozambique' }, 165 | 'NA': { name: 'Namibia' }, 166 | 'NC': { name: 'New Caledonia' }, 167 | 'NE': { name: 'Niger' }, 168 | 'NF': { name: 'Norfolk Island' }, 169 | 'NG': { name: 'Nigeria' }, 170 | 'NI': { name: 'Nicaragua' }, 171 | 'NL': { name: 'Netherlands' }, 172 | 'NO': { name: 'Norway' }, 173 | 'NP': { name: 'Nepal' }, 174 | 'NR': { name: 'Nauru' }, 175 | 'NU': { name: 'Niue' }, 176 | 'NZ': { name: 'New Zealand' }, 177 | 'OM': { name: 'Oman' }, 178 | 'PA': { name: 'Panama' }, 179 | 'PE': { name: 'Peru' }, 180 | 'PF': { name: 'French Polynesia' }, 181 | 'PG': { name: 'Papua New Guinea' }, 182 | 'PH': { name: 'Philippines' }, 183 | 'PK': { name: 'Pakistan' }, 184 | 'PL': { name: 'Poland' }, 185 | 'PM': { name: 'Saint Pierre and Miquelon' }, 186 | 'PN': { name: 'Pitcairn' }, 187 | 'PR': { name: 'Puerto Rico' }, 188 | 'PS': { name: 'Occupied Palestinian Territory' }, 189 | 'PT': { name: 'Portugal' }, 190 | 'PW': { name: 'Palau' }, 191 | 'PY': { name: 'Paraguay' }, 192 | 'QA': { name: 'Qatar' }, 193 | 'RE': { name: 'Réunion' }, 194 | 'RO': { name: 'Romania' }, 195 | 'RS': { name: 'Serbia' }, 196 | 'RU': { name: 'Russian Federation' }, 197 | 'RW': { name: 'Rwanda' }, 198 | 'SA': { name: 'Saudi Arabia' }, 199 | 'SB': { name: 'Solomon Islands' }, 200 | 'SC': { name: 'Seychelles' }, 201 | 'SD': { name: 'Sudan' }, 202 | 'SE': { name: 'Sweden' }, 203 | 'SG': { name: 'Singapore' }, 204 | 'SH': { name: 'Saint Helena' }, 205 | 'SI': { name: 'Slovenia' }, 206 | 'SJ': { name: 'Svalbard and Jan Mayen Islands' }, 207 | 'SK': { name: 'Slovakia' }, 208 | 'SL': { name: 'Sierra Leone' }, 209 | 'SM': { name: 'San Marino' }, 210 | 'SN': { name: 'Senegal' }, 211 | 'SO': { name: 'Somalia' }, 212 | 'SR': { name: 'Suriname' }, 213 | 'SS': { name: 'South Sudan' }, 214 | 'ST': { name: 'Sao Tome and Principe' }, 215 | 'SV': { name: 'El Salvador' }, 216 | 'SX': { name: 'Sint Maarten (Dutch part)' }, 217 | 'SY': { name: 'Syrian Arab Republic' }, 218 | 'SZ': { name: 'Swaziland' }, 219 | 'TC': { name: 'Turks and Caicos Islands' }, 220 | 'TD': { name: 'Chad' }, 221 | 'TF': { name: 'French Southern Territories' }, 222 | 'TG': { name: 'Togo' }, 223 | 'TH': { name: 'Thailand' }, 224 | 'TJ': { name: 'Tajikistan' }, 225 | 'TK': { name: 'Tokelau' }, 226 | 'TL': { name: 'Timor-Leste' }, 227 | 'TM': { name: 'Turkmenistan' }, 228 | 'TN': { name: 'Tunisia' }, 229 | 'TO': { name: 'Tonga' }, 230 | 'TR': { name: 'Turkey' }, 231 | 'TT': { name: 'Trinidad and Tobago' }, 232 | 'TV': { name: 'Tuvalu' }, 233 | 'TW': { name: 'Taiwan' }, 234 | 'TZ': { name: 'Tanzania (United Republic of)' }, 235 | 'UA': { name: 'Ukraine' }, 236 | 'UG': { name: 'Uganda' }, 237 | 'US': { name: 'United States' }, 238 | 'UY': { name: 'Uruguay' }, 239 | 'UZ': { name: 'Uzbekistan' }, 240 | 'VA': { name: 'Holy See (Vatican City)' }, 241 | 'VC': { name: 'Saint Vincent and the Grenadines' }, 242 | 'VE': { name: 'Venezuela' }, 243 | 'VG': { name: 'British Virgin Islands' }, 244 | 'VN': { name: 'Vietnam' }, 245 | 'VU': { name: 'Vanuatu' }, 246 | 'WF': { name: 'Wallis and Futuna Islands' }, 247 | 'WS': { name: 'Samoa' }, 248 | 'XK': { name: 'Kosovo' }, 249 | 'YE': { name: 'Yemen' }, 250 | 'YT': { name: 'Mayotte' }, 251 | 'ZA': { name: 'South Africa' }, 252 | 'ZM': { name: 'Zambia' }, 253 | // $fold-end$ 254 | 'ZW': { name: 'Zimbabwe' } 255 | } 256 | 257 | // use fixed sort order for a subset of values 258 | countries.US.sortKey = '1' 259 | countries.CA.sortKey = '2' 260 | 261 | // remove diacritics in folded values, so that names like "Åland Islands" 262 | // are found and sort with "a" 263 | function foldValue(value: string): string { 264 | return removeDiacritics(value).toLowerCase() 265 | } 266 | 267 | // fold names in advance, since it is a relatively expensive operation 268 | for (let code of Object.keys(countries)) { 269 | const country = countries[code] 270 | const name = country.name 271 | country.foldedName = foldValue(name) 272 | if (!country.sortKey) { 273 | country.sortKey = country.foldedName 274 | } 275 | } 276 | 277 | class CountryAdapter extends ItemAdapter { 278 | getTextRepresentations(item) { 279 | return [item.key.toLowerCase(), item.foldedName] 280 | } 281 | foldValue(value) { 282 | return foldValue(value) 283 | } 284 | renderItem(item) { 285 | return
{item.name}{item.key}
286 | } 287 | } 288 | CountryAdapter.instance = new CountryAdapter() 289 | 290 | return function render({ country, onChange }) { 291 | return 292 |
293 | 294 | {country && country.key && `Country code: ${country.key}`} 295 | 296 |
297 | Country 298 | 307 |
308 | } 309 | -------------------------------------------------------------------------------- /src/Autosuggest.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import classNames from 'classnames' 4 | import shallowEqual from 'fbjs/lib/shallowEqual' 5 | import keycode from 'keycode' 6 | import PropTypes from 'prop-types' 7 | import React from 'react' 8 | import { Dropdown } from 'react-bootstrap' 9 | import ReactDOM from 'react-dom' 10 | import warning from 'warning' 11 | 12 | import Choices from './Choices' 13 | import Suggestions from './Suggestions' 14 | import ItemAdapter from './ItemAdapter' 15 | export { ItemAdapter } 16 | import ListAdapter from './ListAdapter' 17 | export { ListAdapter } 18 | import EmptyListAdapter from './EmptyListAdapter' 19 | export { EmptyListAdapter } 20 | import ArrayListAdapter from './ArrayListAdapter' 21 | export { ArrayListAdapter } 22 | import MapListAdapter from './MapListAdapter' 23 | export { MapListAdapter } 24 | import ObjectListAdapter from './ObjectListAdapter' 25 | export { ObjectListAdapter } 26 | import type { Node } from './types' 27 | 28 | type Props = { 29 | addonAfter?: Node; 30 | addonBefore?: Node; 31 | allowDuplicates?: boolean; 32 | bsSize?: 'small' | 'large'; 33 | buttonAfter?: Node; 34 | buttonBefore?: Node; 35 | choicesClass?: React.Component<*, *, *> | string; 36 | closeOnCompletion?: boolean; 37 | datalist?: any; 38 | datalistAdapter?: ListAdapter<*, *>; 39 | datalistMessage?: Node; 40 | datalistOnly?: boolean; 41 | datalistPartial?: boolean; 42 | defaultValue?: any; 43 | disabled?: boolean; 44 | dropup?: boolean; 45 | groupClassName?: string; 46 | inputSelect?: (input: HTMLInputElement, value: string, completion: string) => void; 47 | itemAdapter?: ItemAdapter<*>; 48 | itemReactKeyPropName?: string; 49 | itemSortKeyPropName?: string; 50 | itemValuePropName?: string; 51 | multiple?: boolean; 52 | onAdd?: (item: any) => void; 53 | onBlur?: (value: any) => void; 54 | onChange?: (value: any) => void; 55 | onDatalistMessageSelect?: () => void; 56 | onFocus?: (value: any) => void; 57 | onRemove?: (index: number) => void; 58 | onSearch?: (search: string) => void; 59 | onSelect?: (item: any) => void; 60 | onToggle?: (open: boolean) => void; 61 | placeholder?: string; 62 | required?: boolean; 63 | searchDebounce?: number; 64 | showToggle?: boolean | 'auto'; 65 | suggestionsClass?: React.Component<*, *, *> | string; 66 | toggleId?: string | number; 67 | type?: string; 68 | value?: any; 69 | valueIsItem?: boolean; 70 | } 71 | 72 | type State = { 73 | open: boolean; 74 | disableFilter: boolean; 75 | inputValue: string; 76 | inputValueKeyPress: number; 77 | inputFocused: boolean; 78 | selectedItems: any[]; 79 | searchValue: ?string; 80 | } 81 | 82 | /** 83 | * Combo-box input component that combines a drop-down list and a single-line 84 | * editable text box. The set of options for the drop-down list can be 85 | * controlled dynamically. Selection of multiple items is supported using a 86 | * tag/pill-style user interface within a simulated text box. 87 | */ 88 | export default class Autosuggest extends React.Component { 89 | static propTypes = { 90 | /** 91 | * Text or component appearing in the input group after the input element 92 | * (and before any button specified in `buttonAfter`). 93 | */ 94 | addonAfter: PropTypes.node, 95 | /** 96 | * Text or component appearing in the input group before the input element 97 | * (and before any button specified in `buttonBefore`). 98 | */ 99 | addonBefore: PropTypes.node, 100 | /** 101 | * Indicates whether duplicate values are allowed in `multiple` mode. 102 | */ 103 | allowDuplicates: PropTypes.bool, 104 | /** 105 | * Specifies the size of the form group and its contained components. 106 | * Leave undefined for normal/medium size. 107 | */ 108 | bsSize: PropTypes.oneOf(['small', 'large']), 109 | /** 110 | * Button component appearing in the input group after the input element 111 | * (and after any add-on specified in `addonAfter`). 112 | */ 113 | buttonAfter: PropTypes.node, 114 | /** 115 | * Button component appearing in the input group before the input element 116 | * (and after any add-on specified in `addonBefore`). 117 | */ 118 | buttonBefore: PropTypes.node, 119 | /** 120 | * React component class used to render the selected items in multiple mode. 121 | */ 122 | choicesClass: PropTypes.oneOfType([ 123 | PropTypes.func, 124 | PropTypes.string 125 | ]), 126 | /** 127 | * Indicates whether the drop-down menu should be closed automatically when 128 | * auto-completion occurs. By default, the menu will remain open, so the 129 | * user can see any additional information about the selected item (such as 130 | * a shorthand code that caused it to be selected). 131 | */ 132 | closeOnCompletion: PropTypes.bool, 133 | /** 134 | * A collection of items (such as an array, object, or Map) used as 135 | * auto-complete suggestions. Each item may have any type supported by the 136 | * `itemAdapter`. The default item adapter has basic support for any 137 | * non-null type: it will initially try to access item properties using the 138 | * configured property names (`itemReactKeyPropName`, `itemSortKeyPropName`, 139 | * and `itemValuePropName`), but will fall back to using the `toString` 140 | * method to obtain these properties to support primitives and other object 141 | * types. 142 | * 143 | * If `datalist` is undefined or null and `onSearch` is not, the datalist 144 | * is assumed to be dynamically populated, and the drop-down toggle will be 145 | * enabled and will trigger `onSearch` the first time it is clicked. 146 | * Conversely, an empty `datalist` or undefined/null `onSearch` indicates 147 | * that there are no auto-complete options. 148 | */ 149 | datalist: PropTypes.any, 150 | /** 151 | * An instance of the ListAdapter class that provides datalist access 152 | * methods required by this component. 153 | */ 154 | datalistAdapter: PropTypes.object, 155 | /** 156 | * Message to be displayed at the end of the datalist. It can be used to 157 | * indicate that data is being fetched asynchronously, that an error 158 | * occurred fetching data, or that additional options can be requested. 159 | * It behaves similarly to a menu item, except that it is not filtered or 160 | * sorted and cannot be selected (except to invoke `onDatalistMessageSelect`). 161 | * Changing this property to a different non-null value while the component 162 | * is focused causes the drop-down menu to be opened, which is useful for 163 | * reporting status, such as that options are being fetched or failed to be 164 | * fetched. 165 | */ 166 | datalistMessage: PropTypes.node, 167 | /** 168 | * Indicates that only values matching an item from the `datalist` property 169 | * are considered valid. For search purposes, intermediate values of the 170 | * underlying `input` element may not match while the component is focused, 171 | * but any non-matching value will be replaced with the previous matching 172 | * value when the component loses focus. 173 | * 174 | * Note that there are two cases where the current (valid) value may not 175 | * correspond to an item in the datalist: 176 | * 177 | * - If the value was provided by the `value` or `defaultValue` property 178 | * and either `datalist` is undefined/null (as opposed to empty) or 179 | * `datalistPartial` is true, the value is assumed to be valid. 180 | * - If `datalist` changes and `datalistPartial` is true, any previously 181 | * valid value is assumed to remain valid. (Conversely, if `datalist` 182 | * changes and `datalistPartial` is false, a previously valid value will 183 | * be invalidated if not in the new `datalist`.) 184 | */ 185 | datalistOnly: PropTypes.bool, 186 | /** 187 | * Indicates that the `datalist` property should be considered incomplete 188 | * for validation purposes. Specifically, if both `datalistPartial` and 189 | * `datalistOnly` are true, changes to the `datalist` will not render 190 | * invalid a value that was previously valid. This is useful in cases where 191 | * a partial datalist is obtained dynamically in response to the `onSearch` 192 | * callback. 193 | */ 194 | datalistPartial: PropTypes.bool, 195 | /** 196 | * Initial value to be rendered when used as an 197 | * [uncontrolled component](https://facebook.github.io/react/docs/forms.html#uncontrolled-components) 198 | * (i.e. no `value` property is supplied). 199 | */ 200 | defaultValue: PropTypes.any, 201 | /** 202 | * Indicates whether the form group is disabled, which causes all of its 203 | * contained elements to ignore input and focus events and to be displayed 204 | * grayed out. 205 | */ 206 | disabled: PropTypes.bool, 207 | /** 208 | * Indicates whether the suggestion list should drop up instead of down. 209 | * 210 | * Note that currently a drop-up list extending past the top of the page is 211 | * clipped, rendering the clipped items inaccessible, whereas a drop-down 212 | * list will extend the page and allow scrolling as necessary. 213 | */ 214 | dropup: PropTypes.bool, 215 | /** 216 | * Custom class name applied to the input group. 217 | */ 218 | groupClassName: PropTypes.string, 219 | /** 220 | * Function used to select a portion of the input value when auto-completion 221 | * occurs. The default implementation selects just the auto-completed 222 | * portion, which is equivalent to: 223 | * 224 | * ```js 225 | * defaultInputSelect(input, value, completion) { 226 | * input.setSelectionRange(value.length, completion.length) 227 | * } 228 | * ``` 229 | */ 230 | inputSelect: PropTypes.func, 231 | /** 232 | * An instance of the ItemAdapter class that provides the item access 233 | * methods required by this component. 234 | */ 235 | itemAdapter: PropTypes.object, 236 | /** 237 | * Name of the item property used for the React component key. If this 238 | * property is not defined, `itemValuePropName` is used instead. If neither 239 | * property is defined, `toString()` is called on the item. 240 | */ 241 | itemReactKeyPropName: PropTypes.string, 242 | /** 243 | * Name of the item property used for sorting items. If this property is not 244 | * defined, `itemValuePropName` is used instead. If neither property is 245 | * defined, `toString()` is called on the item. 246 | */ 247 | itemSortKeyPropName: PropTypes.string, 248 | /** 249 | * Name of item property used for the input element value. If this property 250 | * is not defined, `toString()` is called on the item. 251 | */ 252 | itemValuePropName: PropTypes.string, 253 | /** 254 | * Enables selection of multiple items. The value property should be an 255 | * array of items. 256 | */ 257 | multiple: PropTypes.bool, 258 | /** 259 | * Callback function called whenever a new value should be appended to the 260 | * array of values in `multiple` mode. The sole argument is the added item. 261 | */ 262 | onAdd: PropTypes.func, 263 | /** 264 | * Callback function called whenever the input focus leaves this component. 265 | * The sole argument is current value (see `onChange for details`). 266 | */ 267 | onBlur: PropTypes.func, 268 | /** 269 | * Callback function called whenever the input value changes to a different 270 | * valid value. Validity depends on properties such as `datalistOnly`, 271 | * `valueIsItem`, and `required`. The sole argument is current value: 272 | * 273 | * - If `multiple` is enabled, the current value is an array of selected 274 | * items. 275 | * - If `valueIsItem` is enabled, the current value is the selected 276 | * datalist item. 277 | * - Otherwise, the current value is the `input` element value. Note that 278 | * if `datalistOnly` or `required` are enabled, only valid values trigger 279 | * a callback. 280 | */ 281 | onChange: PropTypes.func, 282 | /** 283 | * Callback function called whenever the datalist item created for 284 | * `datalistMessage` is selected. If this property is null, the associated 285 | * item is displayed as disabled. 286 | */ 287 | onDatalistMessageSelect: PropTypes.func, 288 | /** 289 | * Callback function called whenever the input focus enters this component. 290 | * The sole argument is current value (see `onChange for details`). 291 | */ 292 | onFocus: PropTypes.func, 293 | /** 294 | * Callback function called whenever a value should be removed from the 295 | * array of values in `multiple` mode. The sole argument is the index of 296 | * the value to remove. 297 | */ 298 | onRemove: PropTypes.func, 299 | /** 300 | * Callback function called periodically when the `input` element value has 301 | * changed. The sole argument is the current value of the `input` element. 302 | * This callback can be used to dynamically populate the `datalist` based on 303 | * the input value so far, e.g. with values obtained from a remote service. 304 | * Once changed, the value must then remain unchanged for `searchDebounce` 305 | * milliseconds before the function will be called. No two consecutive 306 | * invocations of the function will be passed the same value (i.e. changing 307 | * and then restoring the value within the debounce interval is not 308 | * considered a change). Note also that the callback can be invoked with an 309 | * empty string, if the user clears the `input` element; this implies that 310 | * any minimum search string length should be imposed by the function. 311 | */ 312 | onSearch: PropTypes.func, 313 | /** 314 | * Callback function called whenever an item from the suggestion list is 315 | * selected (regardless of whether it is clicked or typed). The sole 316 | * argument is the selected item. 317 | */ 318 | onSelect: PropTypes.func, 319 | /** 320 | * Callback function called whenever the drop-down list of suggestions is 321 | * opened or closed. The sole argument is a boolean value indicating whether 322 | * the list is open. 323 | */ 324 | onToggle: PropTypes.func, 325 | /** 326 | * Placeholder text propagated to the underlying `input` element (when 327 | * `multiple` is false or no items have been selected). 328 | */ 329 | placeholder: PropTypes.string, 330 | /** 331 | * `required` property passed to the `input` element (when `multiple` is 332 | * false or no items have been selected). 333 | */ 334 | required: PropTypes.bool, 335 | /** 336 | * The number of milliseconds that must elapse between the last change to 337 | * the `input` element value and a call to `onSearch`. The default is 250. 338 | */ 339 | searchDebounce: PropTypes.number, 340 | /** 341 | * Indicates whether to show the drop-down toggle. If set to `auto`, the 342 | * toggle is shown only when the `datalist` is non-empty or dynamic. 343 | */ 344 | showToggle: PropTypes.oneOfType([ 345 | PropTypes.bool, 346 | PropTypes.oneOf(['auto']) 347 | ]), 348 | /** 349 | * React component class used to render the drop-down list of suggestions. 350 | */ 351 | suggestionsClass: PropTypes.oneOfType([ 352 | PropTypes.func, 353 | PropTypes.string 354 | ]), 355 | /** 356 | * ID supplied to the drop-down toggle and used by the drop-down menu to 357 | * refer to it. 358 | */ 359 | toggleId: PropTypes.oneOfType([ 360 | PropTypes.string, 361 | PropTypes.number 362 | ]), 363 | /** 364 | * `type` property supplied to the contained `input` element. Only textual 365 | * types should be specified, such as `text`, `search`, `email`, `tel`, 366 | * `number`, or perhaps `textarea`. Note that the browser may supply 367 | * additional UI elements for some types (e.g. increment/decrement buttons 368 | * for `number`) that may need additional styling or may interfere with 369 | * UI elements supplied by this component. 370 | */ 371 | type: PropTypes.string, 372 | /** 373 | * The value to be rendered by the component. If unspecified, the component 374 | * behaves like an [uncontrolled component](https://facebook.github.io/react/docs/forms.html#uncontrolled-components). 375 | */ 376 | value: PropTypes.any, 377 | /** 378 | * Indicates that the `value` property should be interpreted as a datalist 379 | * item, as opposed to the string value of the underlying `input` element. 380 | * When false (the default), the `value` property (if specified) is 381 | * expected to be a string and corresponds (indirectly) to the `value` 382 | * property of the underlying `input` element. When true, the `value` 383 | * property is expected to be a datalist item whose display value (as 384 | * provided by the `itemAdapter`) is used as the `input` element value. 385 | * This property also determines whether the argument to the `onChange` 386 | * callback is the `input` value or a datalist item. 387 | * 388 | * Note that unless `datalistOnly` is also true, items may also be created 389 | * dynamically using the `newFromValue` method of the `itemAdapter`. 390 | * 391 | * Also note that this property is ignored if `multiple` is true; in that 392 | * case, the `value` property and `onChange` callback argument are 393 | * implicitly an array of datalist items. 394 | */ 395 | valueIsItem: PropTypes.bool 396 | }; 397 | 398 | static contextTypes = { 399 | $bs_formGroup: PropTypes.object 400 | }; 401 | 402 | static defaultInputSelect(input: HTMLInputElement, value: string, completion: string) { 403 | // https://html.spec.whatwg.org/multipage/forms.html#do-not-apply 404 | switch (input.type) { 405 | case 'text': 406 | case 'search': 407 | case 'url': 408 | case 'tel': 409 | case 'password': 410 | case 'textarea': 411 | // istanbul ignore else 412 | if (input.setSelectionRange) { 413 | input.setSelectionRange(value.length, completion.length) 414 | } else if (input.createTextRange) { // old IE 415 | const range = input.createTextRange() 416 | range.moveEnd('character', completion.length) 417 | range.moveStart('character', value.length) 418 | range.select() 419 | } 420 | } 421 | } 422 | 423 | static defaultProps = { 424 | closeOnCompletion: false, 425 | datalistOnly: false, 426 | datalistPartial: false, 427 | disabled: false, 428 | dropup: false, 429 | inputSelect: Autosuggest.defaultInputSelect, 430 | multiple: false, 431 | itemReactKeyPropName: 'key', 432 | itemSortKeyPropName: 'sortKey', 433 | itemValuePropName: 'value', 434 | searchDebounce: 250, 435 | showToggle: 'auto', 436 | type: 'text', 437 | valueIsItem: false 438 | }; 439 | 440 | state: State; 441 | 442 | _itemAdapter: ItemAdapter<*>; 443 | _listAdapter: ListAdapter<*, *>; 444 | _lastValidItem: any; 445 | _lastValidValue: string; 446 | _foldedInputValue: string; 447 | _pseudofocusedItem: any; 448 | _keyPressCount: number; 449 | _inputItem: any; 450 | _inputItemEphemeral: boolean; 451 | _valueIsValid : boolean; 452 | _valueWasValidated: boolean; 453 | _lastOnChangeValue: any; 454 | _lastOnSelectValue: any; 455 | _autoCompleteAfterRender: ?boolean; 456 | _menuFocusedBeforeUpdate: ?boolean; 457 | _lastOpenEventType: ?string; 458 | _focusTimeoutId: ?number; 459 | _focused: ?boolean; 460 | _searchTimeoutId: ?number; 461 | 462 | constructor(props: Props, ...args: any) { 463 | super(props, ...args) 464 | /* istanbul ignore next: https://github.com/gotwarlost/istanbul/issues/690#issuecomment-265718617 */ 465 | this._itemAdapter = props.itemAdapter || new ItemAdapter() 466 | this._itemAdapter.receiveProps(props) 467 | 468 | this._listAdapter = props.datalistAdapter || 469 | this._getListAdapter(props.datalist) 470 | this._listAdapter.receiveProps(props, this._itemAdapter) 471 | 472 | const { inputValue, inputItem, inputItemEphemeral, selectedItems } = 473 | this._getValueFromProps(props) 474 | this._setValueMeta(inputItem, inputItemEphemeral, true, true) 475 | this._lastValidItem = inputItem 476 | this._lastValidValue = inputValue 477 | this._keyPressCount = 0 478 | 479 | this.state = { 480 | open: false, 481 | disableFilter: false, 482 | inputValue, 483 | inputValueKeyPress: 0, 484 | inputFocused: false, 485 | selectedItems, 486 | searchValue: null 487 | } 488 | this._lastOnChangeValue = this._getCurrentValue() 489 | this._lastOnSelectValue = inputItem 490 | 491 | const self: any = this // https://github.com/facebook/flow/issues/1517 492 | self._renderSelected = this._renderSelected.bind(this) 493 | self._getItemKey = this._getItemKey.bind(this) 494 | self._isSelectedItem = this._isSelectedItem.bind(this) 495 | self._renderSuggested = this._renderSuggested.bind(this) 496 | self._handleToggleClick = this._handleToggleClick.bind(this) 497 | self._handleInputChange = this._handleInputChange.bind(this) 498 | self._handleItemSelect = this._handleItemSelect.bind(this) 499 | self._removeItem = this._removeItem.bind(this) 500 | self._handleShowAll = this._handleShowAll.bind(this) 501 | self._handleKeyDown = this._handleKeyDown.bind(this) 502 | self._handleKeyPress = this._handleKeyPress.bind(this) 503 | self._handleMenuClose = this._handleMenuClose.bind(this) 504 | self._handleInputFocus = this._handleInputFocus.bind(this) 505 | self._handleInputBlur = this._handleInputBlur.bind(this) 506 | self._handleFocus = this._handleFocus.bind(this) 507 | self._handleBlur = this._handleBlur.bind(this) 508 | } 509 | 510 | _getListAdapter(list: L): ListAdapter<*, L> { 511 | if (list == null) { 512 | return (new EmptyListAdapter(): any) 513 | } else if (Array.isArray(list)) { 514 | return (new ArrayListAdapter(): any) 515 | } else if (list instanceof Map) { 516 | return (new MapListAdapter(): any) 517 | } else if (typeof list === 'object') { 518 | return (new ObjectListAdapter(): any) 519 | } else { 520 | throw Error('Unexpected datalist type: datalistAdapter required') 521 | } 522 | } 523 | 524 | _getValueFromProps(props: Props): { 525 | inputValue: string, 526 | inputItem: any, 527 | inputItemEphemeral: boolean, 528 | selectedItems: any[] 529 | } { 530 | let inputValue = '' 531 | let inputItem = null 532 | let inputItemEphemeral = false 533 | let selectedItems = [] 534 | const value = props.value || props.defaultValue 535 | if (value != null) { 536 | if (props.multiple) { 537 | if (Array.isArray(value)) { 538 | selectedItems = this._filterItems(value, props) 539 | } else { 540 | warning(!value, 'Array expected for value property') 541 | } 542 | } else if (props.valueIsItem) { 543 | const itemValue = this._itemAdapter.getInputValue(value) 544 | if (props.datalist != null) { 545 | inputItem = this._listAdapter.findMatching(props.datalist, itemValue) 546 | if (inputItem != null) { 547 | inputValue = inputItem === value ? itemValue : 548 | this._itemAdapter.getInputValue(inputItem) 549 | } else if (props.datalistOnly && !props.datalistPartial) { 550 | this._warnInvalidValue(value) 551 | } else { 552 | inputValue = itemValue 553 | inputItem = value 554 | } 555 | } else { 556 | inputValue = itemValue 557 | inputItem = value 558 | } 559 | } else if (value) { 560 | if (props.datalist != null) { 561 | inputItem = this._listAdapter.findMatching(props.datalist, value) 562 | if (inputItem != null) { 563 | inputValue = this._itemAdapter.getInputValue(inputItem) 564 | } else if (props.datalistOnly && !props.datalistPartial) { 565 | this._warnInvalidValue(value) 566 | } else { 567 | inputValue = value.toString() 568 | inputItem = this._itemAdapter.newFromValue(value) 569 | inputItemEphemeral = true 570 | } 571 | } else { 572 | inputValue = value.toString() 573 | inputItem = this._itemAdapter.newFromValue(value) 574 | inputItemEphemeral = true 575 | } 576 | } 577 | } 578 | return { inputValue, inputItem, inputItemEphemeral, selectedItems } 579 | } 580 | 581 | _filterItems(items: any[], props: Props): any[] { 582 | if (props.datalist != null || !props.allowDuplicates) { 583 | const result = [] 584 | const valueSet = {} 585 | let different = false 586 | for (let item of items) { 587 | const value = this._itemAdapter.getInputValue(item) 588 | if (!props.allowDuplicates && valueSet[value]) { 589 | different = true 590 | continue 591 | } 592 | const listItem = this._listAdapter.findMatching(props.datalist, value) 593 | if (listItem != null) { 594 | result.push(listItem) 595 | valueSet[value] = true 596 | different = true 597 | } else if (props.datalistOnly && !props.datalistPartial) { 598 | this._warnInvalidValue(value) 599 | different = true 600 | } else { 601 | result.push(item) 602 | valueSet[value] = true 603 | } 604 | } 605 | if (different) { 606 | return result 607 | } 608 | } 609 | return items 610 | } 611 | 612 | _warnInvalidValue(value: string) { 613 | warning(false, 'Value "%s" does not match any datalist value', value) 614 | } 615 | 616 | _setInputValue(value: string, callback?: () => void) { 617 | // track keypress count in state so re-render is forced even if value is 618 | // unchanged; this is necessary when typing over the autocompleted range 619 | // with matching characters to properly maintain the input selection range 620 | this.setState({ 621 | inputValue: value, 622 | inputValueKeyPress: this._keyPressCount 623 | }, callback) 624 | } 625 | 626 | _setValueMeta( 627 | inputItem: any, 628 | inputItemEphemeral: boolean = false, 629 | isValid: boolean = inputItem != null, 630 | validated: boolean = isValid) { 631 | this._inputItem = inputItem 632 | this._inputItemEphemeral = inputItemEphemeral 633 | this._valueIsValid = isValid 634 | this._valueWasValidated = validated 635 | } 636 | 637 | _clearInput() { 638 | this._setValueMeta(null, false, true, true) 639 | this._setInputValue('') 640 | } 641 | 642 | _getValueUsing(props: Props, inputValue: string, inputItem: any, selectedItems: any[]) { 643 | return props.multiple ? selectedItems : 644 | props.valueIsItem ? inputItem : inputValue 645 | } 646 | 647 | _getCurrentValue() { 648 | return this._getValueUsing( 649 | this.props, this.state.inputValue, this._inputItem, this.state.selectedItems) 650 | } 651 | 652 | componentDidMount() { 653 | // IE8 can jump cursor position if not immediately updated to typed value; 654 | // for other browsers, we can avoid re-rendering for the auto-complete 655 | this._autoCompleteAfterRender = !this.refs.input.setSelectionRange 656 | } 657 | 658 | componentWillReceiveProps(nextProps: Props) { 659 | if (nextProps.itemAdapter != this.props.itemAdapter) { 660 | this._itemAdapter = nextProps.itemAdapter || new ItemAdapter() 661 | } 662 | this._itemAdapter.receiveProps(nextProps) 663 | 664 | if (nextProps.datalist != this.props.datalist || 665 | nextProps.datalistAdapter != this.props.datalistAdapter) { 666 | if (nextProps.datalistAdapter) { 667 | this._listAdapter = nextProps.datalistAdapter 668 | } else { 669 | const listAdapter = this._getListAdapter(nextProps.datalist) 670 | if (listAdapter.constructor != this._listAdapter.constructor) { 671 | this._listAdapter = listAdapter 672 | } 673 | } 674 | } 675 | this._listAdapter.receiveProps(nextProps, this._itemAdapter) 676 | 677 | // if props.value changes (to a value other than the current state), or 678 | // validation changes to make state invalid, propagate props.value to state 679 | const nextValue = nextProps.value 680 | let { inputValue } = this.state 681 | const valueChanged = nextValue !== this.props.value && 682 | nextValue !== this._getValueUsing(nextProps, inputValue, this._inputItem, 683 | this.state.selectedItems) 684 | let inputItem, inputValueInvalid, propsValueInvalid, validateSelected 685 | if (!valueChanged) { 686 | if (nextProps.datalistOnly) { 687 | const canValidate = !nextProps.datalistPartial && nextProps.datalist != null 688 | const validationChanged = !this.props.datalistOnly || 689 | (!nextProps.datalistPartial && this.props.datalistPartial) || 690 | (nextProps.datalist != this.props.datalist) 691 | if (inputValue) { 692 | inputItem = this._listAdapter.findMatching(nextProps.datalist, inputValue) 693 | if (inputItem == null) { 694 | if (!canValidate && !this._inputItemEphemeral) { 695 | inputItem = this._inputItem 696 | } else if (this._inputItemEphemeral && nextValue === inputValue) { 697 | propsValueInvalid = true 698 | } 699 | } 700 | inputValueInvalid = inputItem == null && validationChanged 701 | // update metadata but don't reset input value if invalid but focused 702 | if (inputValueInvalid && this._focused) { 703 | this._setValueMeta(null, false, false, true) 704 | if (validationChanged && canValidate && this._lastValidItem != null) { 705 | // revalidate last valid item, which will be restored on blur 706 | this._lastValidItem = this._listAdapter.findMatching( 707 | nextProps.datalist, this._lastValidValue) 708 | if (this._lastValidItem == null) { 709 | this._lastValidValue = '' 710 | } 711 | } 712 | inputValueInvalid = false 713 | } 714 | } else { 715 | inputItem = null 716 | inputValueInvalid = false 717 | } 718 | validateSelected = nextProps.multiple && canValidate && validationChanged 719 | } 720 | if (nextProps.multiple && !nextProps.allowDuplicates && this.props.allowDuplicates) { 721 | validateSelected = true 722 | } 723 | } 724 | // inputValueInvalid implies !multiple, since inputValue of multiple should 725 | // be blank when not focused 726 | if (valueChanged || inputValueInvalid) { 727 | let inputItemEphemeral, selectedItems 728 | if (propsValueInvalid) { 729 | inputValue = '' 730 | inputItemEphemeral = false 731 | selectedItems = [] 732 | } else { 733 | ({ inputValue, inputItem, inputItemEphemeral, selectedItems } = 734 | this._getValueFromProps(nextProps)) 735 | } 736 | // if props.value change resolved to current state item, don't reset input 737 | if (inputItem !== this._inputItem || !this._focused) { 738 | this._setValueMeta(inputItem, inputItemEphemeral, true, true) 739 | this._setInputValue(inputValue) 740 | this.setState({ selectedItems }) 741 | validateSelected = false 742 | this._lastValidItem = inputItem 743 | this._lastValidValue = inputValue 744 | // suppress onChange (but not onSelect) if value came from props 745 | if (valueChanged) { 746 | this._lastOnChangeValue = this._getValueUsing(nextProps, inputValue, 747 | inputItem, selectedItems) 748 | } 749 | } else if (valueChanged && nextProps.multiple) { 750 | this.setState({ selectedItems }) 751 | } 752 | } else if (inputValue && nextProps.datalist != this.props.datalist && this._focused) { 753 | // if datalist changed but value didn't, attempt to autocomplete 754 | this._checkAutoComplete(inputValue, nextProps) 755 | } 756 | if (validateSelected) { 757 | const selectedItems = this._filterItems(this.state.selectedItems, nextProps) 758 | this.setState({ selectedItems }) 759 | } 760 | 761 | // open dropdown if datalist message is set while focused 762 | if (nextProps.datalistMessage && 763 | nextProps.datalistMessage != this.props.datalistMessage && 764 | this._focused) { 765 | this._open('message', nextProps) 766 | } 767 | } 768 | 769 | shouldComponentUpdate(nextProps: Props, nextState: State): boolean { 770 | return !shallowEqual(this.props, nextProps) || !shallowEqual(this.state, nextState) 771 | } 772 | 773 | componentWillUpdate(nextProps: Props, nextState: State) { 774 | const { suggestions } = this.refs 775 | this._menuFocusedBeforeUpdate = suggestions && suggestions.isFocused() 776 | 777 | const nextInputValue = nextState.inputValue 778 | if (nextInputValue != this.state.inputValue) { 779 | let inputItem, inputItemEphemeral, isValid 780 | if (!this._valueWasValidated) { 781 | if (nextInputValue) { 782 | inputItem = this._listAdapter.findMatching(nextProps.datalist, nextInputValue) 783 | if (inputItem == null && !nextProps.datalistOnly) { 784 | inputItem = this._itemAdapter.newFromValue(nextInputValue) 785 | inputItemEphemeral = true 786 | isValid = true 787 | } else { 788 | inputItemEphemeral = false 789 | isValid = inputItem != null 790 | } 791 | } else { 792 | inputItem = null 793 | inputItemEphemeral = false 794 | isValid = !nextProps.required 795 | } 796 | this._setValueMeta(inputItem, inputItemEphemeral, isValid) 797 | } else { 798 | inputItem = this._inputItem 799 | isValid = this._valueIsValid 800 | } 801 | if (isValid) { 802 | this._lastValidItem = inputItem 803 | this._lastValidValue = inputItem && !inputItemEphemeral ? 804 | this._itemAdapter.getInputValue(inputItem) : nextInputValue 805 | } 806 | 807 | if (isValid) { 808 | const { multiple, onChange } = nextProps 809 | if (!multiple && onChange) { 810 | const value = this._getValueUsing( 811 | nextProps, nextInputValue, inputItem, nextState.selectedItems) 812 | if (value !== this._lastOnChangeValue) { 813 | this._lastOnChangeValue = value 814 | onChange(value) 815 | } 816 | } 817 | 818 | const { onSelect } = nextProps 819 | if (onSelect && inputItem !== this._lastOnSelectValue) { 820 | this._lastOnSelectValue = inputItem 821 | onSelect(inputItem) 822 | } 823 | } 824 | } 825 | 826 | const { onToggle } = nextProps 827 | if (onToggle && nextState.open != this.state.open) { 828 | onToggle(nextState.open) 829 | } 830 | } 831 | 832 | componentDidUpdate(prevProps: Props, prevState: State) { 833 | if ((this.state.open && !prevState.open && 834 | this._lastOpenEventType === 'keydown') || 835 | (this.state.disableFilter && !prevState.disableFilter && 836 | this._menuFocusedBeforeUpdate)) { 837 | this.refs.suggestions.focusFirst() 838 | } else if (!this.state.open && prevState.open) { // closed 839 | if (this._menuFocusedBeforeUpdate) { 840 | this._menuFocusedBeforeUpdate = false 841 | this._focusInput() 842 | } 843 | } 844 | } 845 | 846 | componentWillUnmount() { 847 | clearTimeout(this._focusTimeoutId) 848 | this._focusTimeoutId = null 849 | clearTimeout(this._searchTimeoutId) 850 | this._searchTimeoutId = null 851 | } 852 | 853 | _focusInput() { 854 | const input = ReactDOM.findDOMNode(this.refs.input) 855 | // istanbul ignore else 856 | if (input instanceof HTMLElement) { 857 | input.focus() 858 | } 859 | } 860 | 861 | _open(eventType: string, props: Props) { 862 | this._lastOpenEventType = eventType 863 | const disableFilter = eventType !== 'autocomplete' && this._hasNoOrExactMatch(props) 864 | this.setState({ open: true, disableFilter }) 865 | 866 | const { onSearch } = props 867 | const { inputValue, searchValue } = this.state 868 | if (onSearch && searchValue !== inputValue) { 869 | this.setState({ searchValue: inputValue }) 870 | onSearch(inputValue) 871 | } 872 | } 873 | 874 | _close() { 875 | this.setState({ open: false }) 876 | } 877 | 878 | _toggleOpen(eventType: string, props: Props) { 879 | if (this.state.open) { 880 | this._close() 881 | } else { 882 | this._open(eventType, props) 883 | } 884 | } 885 | 886 | _canOpen(): boolean { 887 | const { datalist } = this.props 888 | return (datalist == null && this.props.onSearch) || 889 | !this._listAdapter.isEmpty(datalist) || 890 | !!this.props.datalistMessage 891 | } 892 | 893 | _hasNoOrExactMatch(props: Props): boolean { 894 | if (this._inputItem != null && !this._inputItemEphemeral) { 895 | return true // exact match 896 | } 897 | const foldedValue = this._itemAdapter.foldValue(this.state.inputValue) 898 | return this._listAdapter.find(props.datalist, 899 | item => this._itemAdapter.itemIncludedByInput(item, foldedValue)) == null 900 | } 901 | 902 | render(): React.Element<*> { 903 | const { showToggle } = this.props 904 | const toggleCanOpen = this._canOpen() 905 | const toggleVisible = showToggle === 'auto' ? toggleCanOpen : showToggle 906 | const classes = { 907 | autosuggest: true, 908 | open: this.state.open, 909 | disabled: this.props.disabled, 910 | dropdown: toggleVisible && !this.props.dropup, 911 | dropup: toggleVisible && this.props.dropup 912 | } 913 | return
918 | {this._renderInputGroup(toggleVisible, toggleCanOpen)} 919 | {this._renderMenu()} 920 |
921 | } 922 | 923 | _renderInputGroup(toggleVisible: boolean, toggleCanOpen: boolean): Node { 924 | const addonBefore = this.props.addonBefore ? ( 925 | 926 | {this.props.addonBefore} 927 | 928 | ) : null 929 | 930 | const addonAfter = this.props.addonAfter ? ( 931 | 932 | {this.props.addonAfter} 933 | 934 | ) : null 935 | 936 | const buttonBefore = this.props.buttonBefore ? ( 937 | 938 | {this.props.buttonBefore} 939 | 940 | ) : null 941 | 942 | // Bootstrap expects the dropdown toggle to be last, 943 | // as it does not reset the right border radius for toggles: 944 | // .input-group-btn:last-child > .btn:not(:last-child):not(.dropdown-toggle) 945 | // { @include border-right-radius(0); } 946 | const toggle = toggleVisible && this._renderToggle(toggleCanOpen) 947 | const buttonAfter = (toggle || this.props.buttonAfter) ? ( 948 | 949 | {this.props.buttonAfter} 950 | {toggle} 951 | 952 | ) : null 953 | 954 | const classes = classNames({ 955 | 'input-group': addonBefore || addonAfter || buttonBefore || buttonAfter, 956 | 'input-group-sm': this.props.bsSize === 'small', 957 | 'input-group-lg': this.props.bsSize === 'large', 958 | 'input-group-toggle': !!toggle 959 | }) 960 | return classes ? ( 961 |
962 | {addonBefore} 963 | {buttonBefore} 964 | {this._renderChoices()} 965 | {addonAfter} 966 | {buttonAfter} 967 |
968 | ) : this._renderChoices() 969 | } 970 | 971 | _renderToggle(canOpen: boolean): Node { 972 | return ( 973 | 982 | ) 983 | } 984 | 985 | _renderChoices(): Node { 986 | if (this.props.multiple) { 987 | const { choicesClass: ChoicesClass = Choices } = this.props 988 | return ( 989 | 1000 | {this._renderInput()} 1001 | 1002 | ) 1003 | } 1004 | return this._renderInput() 1005 | } 1006 | 1007 | // autobind 1008 | _renderSelected(item: any): Node { 1009 | return this._itemAdapter.renderSelected(item) 1010 | } 1011 | 1012 | _renderInput(): Node { 1013 | const formGroup = this.context.$bs_formGroup 1014 | const controlId = formGroup && formGroup.controlId 1015 | const extraProps = {} 1016 | for (let key of Object.keys(this.props)) { 1017 | if (!Autosuggest.propTypes[key]) { 1018 | extraProps[key] = this.props[key] 1019 | } 1020 | } 1021 | const noneSelected = !this.props.multiple || !this.state.selectedItems.length 1022 | // set autoComplete off to avoid a redundant browser drop-down menu, 1023 | // but allow it to be overridden by extra props for auto-fill purposes 1024 | 1025 | const _input = this.props.type === 'textarea' ? 'textarea' : 'input' 1026 | return <_input 1027 | autoComplete="off" 1028 | {...extraProps} 1029 | className={classNames(this.props.className, 1030 | { 'form-control': !this.props.multiple })} 1031 | ref="input" 1032 | key="input" 1033 | id={controlId} 1034 | disabled={this.props.disabled} 1035 | required={this.props.required && noneSelected} 1036 | placeholder={noneSelected ? this.props.placeholder : undefined} 1037 | type={this.props.type} 1038 | value={this.state.inputValue} 1039 | onChange={this._handleInputChange} 1040 | onKeyDown={this._handleKeyDown} 1041 | onKeyPress={this._handleKeyPress} 1042 | onFocus={this._handleInputFocus} 1043 | onBlur={this._handleInputBlur} /> 1044 | } 1045 | 1046 | _renderMenu(): ?Node { 1047 | this._pseudofocusedItem = null 1048 | const { open } = this.state 1049 | if (!open) { 1050 | return null 1051 | } 1052 | const { datalist } = this.props 1053 | const foldedValue = this._itemAdapter.foldValue(this.state.inputValue) 1054 | this._foldedInputValue = foldedValue 1055 | let items 1056 | if (this.state.disableFilter) { 1057 | items = this._listAdapter.toArray(datalist) 1058 | } else { 1059 | items = this._listAdapter.filter(datalist, item => 1060 | this._itemAdapter.itemIncludedByInput(item, foldedValue) && 1061 | this._allowItem(item)) 1062 | } 1063 | items = this._itemAdapter.sortItems(items, foldedValue) 1064 | const filtered = items.length < this._listAdapter.getLength(datalist) 1065 | // visually indicate that first item will be selected if Enter is pressed 1066 | // while the input element is focused (unless multiple and not datalist-only) 1067 | let focusedIndex 1068 | if (items.length > 0 && this.state.inputFocused && 1069 | (!this.props.multiple || this.props.datalistOnly)) { 1070 | this._pseudofocusedItem = items[focusedIndex = 0] 1071 | } 1072 | const { suggestionsClass: SuggestionsClass = Suggestions, 1073 | datalistMessage, onDatalistMessageSelect, toggleId } = this.props 1074 | return 1088 | } 1089 | 1090 | _allowItem(item: any): boolean { 1091 | if (this.props.allowDuplicates) { 1092 | return true 1093 | } 1094 | const value = this._itemAdapter.getInputValue(item) 1095 | return !this.state.selectedItems.find( 1096 | i => this._itemAdapter.getInputValue(i) === value) 1097 | } 1098 | 1099 | // autobind 1100 | _getItemKey(item: any): string | number { 1101 | return this._itemAdapter.getReactKey(item) 1102 | } 1103 | 1104 | // autobind 1105 | _isSelectedItem(item: any): boolean { 1106 | return this._itemAdapter.itemMatchesInput(item, this._foldedInputValue) 1107 | } 1108 | 1109 | // autobind 1110 | _renderSuggested(item: any): Node { 1111 | return this._itemAdapter.renderSuggested(item) 1112 | } 1113 | 1114 | // autobind 1115 | _handleToggleClick() { 1116 | this._toggleOpen('click', this.props) 1117 | } 1118 | 1119 | // autobind 1120 | _handleInputChange(event: SyntheticInputEvent) { 1121 | const { value } = (event.target: Object) 1122 | // prevent auto-complete on backspace/delete/copy/paste/etc. 1123 | const allowAutoComplete = this._keyPressCount > this.state.inputValueKeyPress 1124 | if (allowAutoComplete && value) { 1125 | if (this._autoCompleteAfterRender) { 1126 | this._setValueMeta() 1127 | this._setInputValue(value, () => { 1128 | this._checkAutoComplete(value, this.props) 1129 | }) 1130 | } else if (!this._checkAutoComplete(value, this.props)) { 1131 | this._setValueMeta() 1132 | this._setInputValue(value) 1133 | } 1134 | } else { 1135 | this._setValueMeta() 1136 | this._setInputValue(value) 1137 | } 1138 | 1139 | // suppress onSearch if can't auto-complete and not open 1140 | if (allowAutoComplete || this.state.open) { 1141 | const { onSearch } = this.props 1142 | if (onSearch) { 1143 | clearTimeout(this._searchTimeoutId) 1144 | this._searchTimeoutId = setTimeout(() => { 1145 | this._searchTimeoutId = null 1146 | if (value != this.state.searchValue) { 1147 | this.setState({ searchValue: value }) 1148 | onSearch(value) 1149 | } 1150 | }, this.props.searchDebounce) 1151 | } 1152 | } 1153 | } 1154 | 1155 | _checkAutoComplete(value: string, props: Props) { 1156 | // open dropdown if any items would be included 1157 | let valueUpdated = false 1158 | const { datalist } = props 1159 | const foldedValue = this._itemAdapter.foldValue(value) 1160 | const includedItems = this._listAdapter.filter(datalist, i => 1161 | this._itemAdapter.itemIncludedByInput(i, foldedValue) && this._allowItem(i)) 1162 | if (includedItems.length > 0) { 1163 | // if only one item is included and the value must come from the list, 1164 | // autocomplete using that item 1165 | const { datalistOnly, datalistPartial } = props 1166 | if (includedItems.length === 1 && datalistOnly && !datalistPartial) { 1167 | const found = includedItems[0] 1168 | const foundValue = this._itemAdapter.getInputValue(found) 1169 | let callback 1170 | const { inputSelect } = props 1171 | if (value != foundValue && inputSelect && 1172 | this._itemAdapter.foldValue(foundValue).startsWith(foldedValue)) { 1173 | const input = this.refs.input 1174 | callback = () => { inputSelect(input, value, foundValue) } 1175 | } 1176 | this._setValueMeta(found) 1177 | this._setInputValue(foundValue, callback) 1178 | valueUpdated = true 1179 | if (this.state.open ? props.closeOnCompletion : 1180 | value != foundValue && !props.closeOnCompletion) { 1181 | this._toggleOpen('autocomplete', props) 1182 | } 1183 | } else { 1184 | // otherwise, just check if any values match, and select the first one 1185 | // (without modifying the input value) 1186 | const found = includedItems.find(i => 1187 | this._itemAdapter.itemMatchesInput(i, foldedValue)) 1188 | if (found) { 1189 | this._setValueMeta(found) 1190 | this._setInputValue(value) 1191 | valueUpdated = true 1192 | } 1193 | // open dropdown unless exactly one matching value was found 1194 | if (!this.state.open && (!found || includedItems.length > 1)) { 1195 | this._open('autocomplete', props) 1196 | } 1197 | } 1198 | } 1199 | return valueUpdated 1200 | } 1201 | 1202 | // autobind 1203 | _handleItemSelect(item: any) { 1204 | if (this.props.multiple) { 1205 | this._addItem(item) 1206 | } else { 1207 | const itemValue = this._itemAdapter.getInputValue(item) 1208 | this._setValueMeta(item) 1209 | this._setInputValue(itemValue) 1210 | } 1211 | this._close() 1212 | } 1213 | 1214 | _addItem(item: any) { 1215 | if (this._allowItem(item)) { 1216 | const selectedItems = [ 1217 | ...this.state.selectedItems, 1218 | item 1219 | ] 1220 | this.setState({ selectedItems }) 1221 | const { onAdd, onChange } = this.props 1222 | if (onAdd) { 1223 | onAdd(item) 1224 | } 1225 | if (onChange) { 1226 | onChange(selectedItems) 1227 | } 1228 | } 1229 | this._clearInput() 1230 | if (this.state.open) { 1231 | this._close() 1232 | } 1233 | } 1234 | 1235 | // autobind 1236 | _removeItem(index: number) { 1237 | const previousItems = this.state.selectedItems 1238 | const selectedItems = previousItems.slice(0, index).concat( 1239 | previousItems.slice(index + 1)) 1240 | this.setState({ selectedItems }) 1241 | const { onRemove, onChange } = this.props 1242 | if (onRemove) { 1243 | onRemove(index) 1244 | } 1245 | if (onChange) { 1246 | onChange(selectedItems) 1247 | } 1248 | } 1249 | 1250 | _addInputValue(): boolean { 1251 | if (this._inputItem) { 1252 | this._addItem(this._inputItem) 1253 | return true 1254 | } 1255 | return false 1256 | } 1257 | 1258 | // autobind 1259 | _handleShowAll() { 1260 | this.setState({ disableFilter: true }) 1261 | } 1262 | 1263 | // autobind 1264 | _handleKeyDown(event: SyntheticKeyboardEvent) { 1265 | if (this.props.disabled) return 1266 | 1267 | switch (event.keyCode || event.which) { 1268 | case keycode.codes.down: 1269 | case keycode.codes['page down']: 1270 | if (this.state.open) { 1271 | this.refs.suggestions.focusFirst() 1272 | } else if (this._canOpen()) { 1273 | this._open('keydown', this.props) 1274 | } 1275 | event.preventDefault() 1276 | break 1277 | case keycode.codes.left: 1278 | case keycode.codes.backspace: 1279 | if (this.refs.choices && this.refs.input && 1280 | this._getCursorPosition(this.refs.input) === 0) { 1281 | this.refs.choices.focusLast() 1282 | event.preventDefault() 1283 | } 1284 | break 1285 | case keycode.codes.right: 1286 | if (this.refs.choices && this.refs.input && 1287 | this._getCursorPosition(this.refs.input) === this.state.inputValue.length) { 1288 | this.refs.choices.focusFirst() 1289 | event.preventDefault() 1290 | } 1291 | break 1292 | case keycode.codes.enter: 1293 | if (this.props.multiple && this.state.inputValue) { 1294 | event.preventDefault() 1295 | if (this._addInputValue()) { 1296 | break 1297 | } 1298 | } 1299 | if (this.state.open && this.state.inputFocused) { 1300 | event.preventDefault() 1301 | if (this._pseudofocusedItem) { 1302 | this._handleItemSelect(this._pseudofocusedItem) 1303 | } else { 1304 | this._close() 1305 | } 1306 | } 1307 | break 1308 | case keycode.codes.esc: 1309 | case keycode.codes.tab: 1310 | this._handleMenuClose(event) 1311 | break 1312 | } 1313 | } 1314 | 1315 | _getCursorPosition(input: React.Component<*, *, *>): ?number { 1316 | const inputNode = ReactDOM.findDOMNode(input) 1317 | // istanbul ignore else 1318 | if (inputNode instanceof HTMLInputElement) { 1319 | return inputNode.selectionStart 1320 | } 1321 | } 1322 | 1323 | // autobind 1324 | _handleKeyPress() { 1325 | ++this._keyPressCount 1326 | } 1327 | 1328 | // autobind 1329 | _handleMenuClose() { 1330 | if (this.state.open) { 1331 | this._close() 1332 | } 1333 | } 1334 | 1335 | // autobind 1336 | _handleInputFocus() { 1337 | this.setState({ inputFocused: true }) 1338 | } 1339 | 1340 | // autobind 1341 | _handleInputBlur() { 1342 | this.setState({ inputFocused: false }) 1343 | } 1344 | 1345 | // autobind 1346 | _handleFocus() { 1347 | if (this._focusTimeoutId) { 1348 | clearTimeout(this._focusTimeoutId) 1349 | this._focusTimeoutId = null 1350 | } else { 1351 | this._focused = true 1352 | const { onFocus } = this.props 1353 | if (onFocus) { 1354 | const value = this._getCurrentValue() 1355 | onFocus(value) 1356 | } 1357 | } 1358 | } 1359 | 1360 | // autobind 1361 | _handleBlur() { 1362 | this._focusTimeoutId = setTimeout(() => { 1363 | this._focusTimeoutId = null 1364 | this._focused = false 1365 | const { inputValue } = this.state 1366 | const { onBlur } = this.props 1367 | if (this.props.multiple) { 1368 | if (inputValue && !this._addInputValue()) { 1369 | this._clearInput() 1370 | } 1371 | } else if (inputValue != this._lastValidValue) { 1372 | // invoke onBlur after state change, rather than immediately 1373 | let callback 1374 | if (onBlur) { 1375 | callback = () => { 1376 | const value = this._getCurrentValue() 1377 | onBlur(value) 1378 | } 1379 | } 1380 | // restore last valid value/item 1381 | this._setValueMeta(this._lastValidItem, false, true, true) 1382 | this._setInputValue(this._lastValidValue, callback) 1383 | return 1384 | } 1385 | if (onBlur) { 1386 | const value = this._getCurrentValue() 1387 | onBlur(value) 1388 | } 1389 | }, 1) 1390 | } 1391 | } 1392 | --------------------------------------------------------------------------------