{
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 |
40 |
41 |
42 |
43 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
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 && }
78 | {datalistMessage && }
83 |
84 | }
85 |
86 | _renderItem(item: any, index: number): React.Element<*> {
87 | const active = this.props.isSelectedItem(item)
88 | return
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 |
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 |
--------------------------------------------------------------------------------