├── .babelrc ├── .eslintrc.js ├── .gitignore ├── .npmignore ├── .prettierrc.js ├── .scripts ├── get_gh_pages_url.js ├── mocha_runner.js ├── prepublish.sh ├── publish_storybook.sh └── user │ ├── prepublish.sh │ └── pretest.js ├── .storybook ├── config.js ├── user │ └── modify_webpack_config.js └── webpack.config.js ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── src ├── index.js ├── stories │ ├── index.js │ └── style.css └── tests │ └── index.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-2", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | const error = 2; 2 | const warn = 1; 3 | const ignore = 0; 4 | 5 | module.exports = { 6 | extends: [ 7 | '@ndelangen/eslint-config-airbnb', 8 | 'prettier', 9 | ], 10 | plugins: [ 11 | 'prettier', 12 | 'react', 13 | ], 14 | parser: 'babel-eslint', 15 | parserOptions: { 16 | sourceType: 'module', 17 | }, 18 | env: { 19 | es6: true, 20 | node: true, 21 | }, 22 | rules: { 23 | strict: [error, 'never'], 24 | 'prettier/prettier': ['warn', { 25 | printWidth: 100, 26 | tabWidth: 2, 27 | bracketSpacing: true, 28 | trailingComma: 'all', 29 | singleQuote: true, 30 | }], 31 | quotes: ['warn', 'single'], 32 | 'class-methods-use-this': ignore, 33 | 'arrow-parens': ['warn', 'as-needed'], 34 | 'space-before-function-paren': ignore, 35 | 'import/no-extraneous-dependencies': [error, { 36 | devDependencies: [ 37 | '**/.scripts/*.js', 38 | '**/stories/*.js', 39 | '**/tests/*.js' 40 | ], 41 | peerDependencies: true 42 | }], 43 | 'import/prefer-default-export': ignore, 44 | 'react/jsx-uses-react': error, 45 | 'react/jsx-uses-vars': error, 46 | 'react/react-in-jsx-scope': error, 47 | 'react/jsx-filename-extension': [warn, { 48 | extensions: ['.js', '.jsx'] 49 | }], 50 | 'jsx-a11y/accessible-emoji': ignore, 51 | 'jsx-a11y/no-static-element-interactions': warn, 52 | 'jsx-a11y/no-autofocus': warn, 53 | 'react/require-default-props': warn, 54 | 'react/forbid-prop-types': warn, 55 | 'react/no-unescaped-entities': ignore, 56 | 'react/no-array-index-key': warn, 57 | 'react/no-string-refs': warn, 58 | 'import/no-named-as-default-member': warn 59 | }, 60 | } 61 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .idea 4 | dist 5 | .git/ 6 | .idea/ 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | .babelrc 3 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = require("./.eslintrc").rules["prettier/prettier"][1]; 2 | -------------------------------------------------------------------------------- /.scripts/get_gh_pages_url.js: -------------------------------------------------------------------------------- 1 | // IMPORTANT 2 | // --------- 3 | // This is an auto generated file with React CDK. 4 | // Do not modify this file. 5 | 6 | const parse = require('git-url-parse'); 7 | var ghUrl = process.argv[2]; 8 | const parsedUrl = parse(ghUrl); 9 | 10 | const ghPagesUrl = 'https://' + parsedUrl.owner + '.github.io/' + parsedUrl.name; 11 | console.log(ghPagesUrl); 12 | -------------------------------------------------------------------------------- /.scripts/mocha_runner.js: -------------------------------------------------------------------------------- 1 | // IMPORTANT 2 | // --------- 3 | // This is an auto generated file with React CDK. 4 | // Do not modify this file. 5 | // Use `.scripts/user/pretest.js instead`. 6 | 7 | require('babel-core/register'); 8 | require('babel-polyfill'); 9 | 10 | // Add jsdom support, which is required for enzyme. 11 | var JSDOM = require('jsdom').JSDOM; 12 | 13 | var exposedProperties = ['window', 'navigator', 'document']; 14 | 15 | const { window } = new JSDOM(`...`); 16 | const { document } = window; 17 | 18 | global.window = window; 19 | global.document = document; 20 | 21 | global.navigator = { 22 | userAgent: 'node.js' 23 | }; 24 | 25 | process.on('unhandledRejection', function (error) { 26 | console.error('Unhandled Promise Rejection:'); 27 | console.error(error && error.stack || error); 28 | }); 29 | 30 | require('./user/pretest.js'); 31 | -------------------------------------------------------------------------------- /.scripts/prepublish.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # IMPORTANT 4 | # --------- 5 | # This is an auto generated file with React CDK. 6 | # Do not modify this file. 7 | # Use `.scripts/user/prepublish.sh instead`. 8 | 9 | echo "=> Transpiling 'src' into ES5 ..." 10 | echo "" 11 | rm -rf ./dist 12 | ./node_modules/.bin/babel --ignore tests,stories --plugins "transform-runtime" ./src --out-dir ./dist 13 | echo "" 14 | echo "=> Transpiling completed." 15 | 16 | . .scripts/user/prepublish.sh 17 | -------------------------------------------------------------------------------- /.scripts/publish_storybook.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # IMPORTANT 4 | # --------- 5 | # This is an auto generated file with React CDK. 6 | # Do not modify this file. 7 | 8 | set -e # exit with nonzero exit code if anything fails 9 | 10 | # get GIT url 11 | 12 | GIT_URL=`git config --get remote.origin.url` 13 | if [[ $GIT_URL == "" ]]; then 14 | echo "This project is not configured with a remote git repo". 15 | exit 1 16 | fi 17 | 18 | # clear and re-create the out directory 19 | rm -rf .out || exit 0; 20 | mkdir .out; 21 | 22 | # run our compile script, discussed above 23 | build-storybook -o .out 24 | 25 | # go to the out directory and create a *new* Git repo 26 | cd .out 27 | git init 28 | 29 | # inside this git repo we'll pretend to be a new user 30 | git config user.name "GH Pages Bot" 31 | git config user.email "hello@ghbot.com" 32 | 33 | # The first and only commit to this new Git repo contains all the 34 | # files present with the commit message "Deploy to GitHub Pages". 35 | git add . 36 | git commit -m "Deploy Storybook to GitHub Pages" 37 | 38 | # Force push from the current repo's master branch to the remote 39 | # repo's gh-pages branch. (All previous history on the gh-pages branch 40 | # will be lost, since we are overwriting it.) We redirect any output to 41 | # /dev/null to hide any sensitive credential data that might otherwise be exposed. 42 | git push --force --quiet $GIT_URL master:gh-pages > /dev/null 2>&1 43 | cd .. 44 | rm -rf .out 45 | 46 | echo "" 47 | echo "=> Storybook deployed to: `node .scripts/get_gh_pages_url.js $GIT_URL`" 48 | -------------------------------------------------------------------------------- /.scripts/user/prepublish.sh: -------------------------------------------------------------------------------- 1 | # Use this file to your own code to run at NPM `prepublish` event. 2 | -------------------------------------------------------------------------------- /.scripts/user/pretest.js: -------------------------------------------------------------------------------- 1 | // Use this file to setup any test utilities. 2 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | // IMPORTANT 2 | // --------- 3 | // This is an auto generated file with React CDK. 4 | // Do not modify this file. 5 | 6 | import { configure } from '@kadira/storybook'; 7 | 8 | function loadStories() { 9 | require('../src/stories'); 10 | } 11 | 12 | configure(loadStories, module); 13 | -------------------------------------------------------------------------------- /.storybook/user/modify_webpack_config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (config) { 2 | // This is the default webpack config defined in the `../webpack.config.js` 3 | // modify as you need. 4 | }; 5 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | // IMPORTANT 2 | // --------- 3 | // This is an auto generated file with React CDK. 4 | // Do not modify this file. 5 | // Use `.storybook/user/modify_webpack_config.js instead`. 6 | 7 | const path = require('path'); 8 | const updateConfig = require('./user/modify_webpack_config'); 9 | 10 | const config = { 11 | module: { 12 | loaders: [ 13 | { 14 | test: /\.css?$/, 15 | loaders: ['style', 'raw'], 16 | include: path.resolve(__dirname, '../'), 17 | }, 18 | ], 19 | } 20 | }; 21 | 22 | updateConfig(config); 23 | module.exports = config; 24 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to React Fuzzy Component 2 | 3 | We welcome your help to make this component better. This document will help to streamline the contributing process and save everyone's precious time. 4 | 5 | ## Development Setup 6 | 7 | This component has been setup with [React CDK](https://github.com/kadirahq/react-cdk). Refer [React CDK documentation](https://github.com/kadirahq/react-cdk)) to get started with the development. 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Your Name. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-fuzzy 2 | fuzzy search in React 3 | 4 | ## Installation 5 | 6 | ```shell 7 | npm install --save react-fuzzy 8 | ``` 9 | 10 | ## Basic Usage 11 | 12 | ```js 13 | const list = [{ 14 | id: 1, 15 | title: 'The Great Gatsby', 16 | author: 'F. Scott Fitzgerald' 17 | }, { 18 | id: 2, 19 | title: 'The DaVinci Code', 20 | author: 'Dan Brown' 21 | }, { 22 | id: 3, 23 | title: 'Angels & Demons', 24 | author: 'Dan Brown' 25 | }]; 26 | 27 | { 32 | // Local state setter defined elsewhere 33 | setSelectedItem(newSelectedItem) 34 | }} 35 | /> 36 | ``` 37 | 38 | ## Custom Result Template 39 | ```js 40 | { 45 | // Local state setter defined elsewhere 46 | setSelectedItem(newSelectedItem) 47 | }} 48 | resultsTemplate={(props, state, styles, clickHandler) => { 49 | return state.results.map((val, i) => { 50 | const style = state.selectedIndex === i ? styles.selectedResultStyle : styles.resultsStyle; 51 | return ( 52 |
clickHandler(i)} 56 | > 57 | {val.title} 58 | by {val.author} 59 |
60 | ); 61 | }); 62 | }} 63 | /> 64 | ``` 65 | 66 | ## Options 67 | 68 | attribute|default|description 69 | ---------|-------|----------- 70 | caseSensitive|false|Indicates whether comparisons should be case sensitive. 71 | className|null|give a custom class name to the root element 72 | inputProps|{}|Props passed directly to the input element. i.e. `defaultValue`, `onChange`, etc. 73 | inputStyle|{}|Styles passed directly to the `input` element. 74 | inputWrapperStyle|{}|Styles passed directly to the `input` wrapper `div`. 75 | isDropdown|false|Hide the result list on blur. 76 | listItemStyle|{}|Styles passed to each item in the dropdown list. 77 | listWrapperStyle|{}|Styles passed directly to the dropdown wrapper. 78 | selectedListItemStyle|{}|Styles passed directly to current 'active' item. 79 | width|430|width of the fuzzy searchbox 80 | distance|100|Determines how close the match must be to the fuzzy location (specified by location). An exact letter match which is distance characters away from the fuzzy location would score as a complete mismatch. A distance of 0 requires the match be at the exact location specified, a distance of 1000 would require a perfect match to be within 800 characters of the location to be found using a threshold of 0.8. 81 | id|null|The name of the identifier property. If specified, the returned result will be a list of the items' identifiers, otherwise it will be a list of the items. 82 | include|[]|An array of values that should be included from the searcher's output. When this array contains elements, each result in the list will be of the form `{ item: ..., include1: ..., include2: ... }`. Values you can include are score, matches. Eg: `{ include: ['score', 'matches' ] }` 83 | maxPatternLength|32|The maximum length of the pattern. The longer the pattern, the more intensive the search operation will be. Whenever the pattern exceeds the maxPatternLength, an error will be thrown. 84 | onSelect| noop | Function to be executed on selection of any result. 85 | keyForDisplayName|title|The key which should be used for list item text. 86 | keys|all[Array]|List of properties that will be searched. This also supports nested properties. 87 | list|null|Array of properties to be filtered. 88 | maxResults|10|Max number of results to show at once. 89 | placeholder|'Search'|Placeholder of the searchbox 90 | resultsTemplate| Func | Template of the dropdown divs 91 | shouldShowDropdownAtStart|false|Allow the searchbox to act as a `filter` dropdown with initial values. Yields all results when the search value is blank. 92 | shouldSort| true | Whether to sort the result list, by score. 93 | sortFn|`Array.prototype.sort`|The function that is used for sorting the result list. 94 | threshold|0.6|At what point does the match algorithm give up. A threshold of `0.0` requires a perfect match (of both letters and location), a threshold of `1.0` would match anything. 95 | tokenize|false|When true, the search algorithm will search individual words and the full string, computing the final score as a function of both. Note that when tokenize is true, the `threshold`, `distance`, and `location` are inconsequential for individual tokens. 96 | verbose|false|Will print to the console. Useful for debugging. 97 | 98 | ## License 99 | MIT @ Ritesh Kumar 100 | 101 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-fuzzy", 3 | "version": "1.3.0", 4 | "description": "React Fuzzy Component", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/ritz078/react-fuzzy-search" 8 | }, 9 | "license": "MIT", 10 | "scripts": { 11 | "prepublish": ". ./.scripts/prepublish.sh", 12 | "lint": "eslint src", 13 | "lintfix": "eslint src --fix", 14 | "testonly": "mocha --require .scripts/mocha_runner src/**/tests/**/*.js", 15 | "test": "npm run lint && npm run testonly", 16 | "test-watch": "npm run testonly -- --watch --watch-extensions js", 17 | "storybook": "start-storybook -p 9010", 18 | "publish-storybook": "bash .scripts/publish_storybook.sh" 19 | }, 20 | "devDependencies": { 21 | "@kadira/storybook": "^2.35.3", 22 | "@ndelangen/eslint-config-airbnb": "14.1.0", 23 | "babel-cli": "^6.24.1", 24 | "babel-core": "^6.24.1", 25 | "babel-eslint": "^10.1.0", 26 | "babel-loader": "^8.1.0", 27 | "babel-plugin-transform-runtime": "^6.23.0", 28 | "babel-polyfill": "^6.23.0", 29 | "babel-preset-es2015": "^6.24.1", 30 | "babel-preset-react": "^6.24.1", 31 | "babel-preset-stage-2": "^6.24.1", 32 | "chai": "^3.5.0", 33 | "commitizen": "^4.2.1", 34 | "cz-conventional-changelog": "^3.3.0", 35 | "enzyme": "^2.8.2", 36 | "eslint": "^7.10.0", 37 | "eslint-config-prettier": "^6.12.0", 38 | "eslint-plugin-import": "^2.2.0", 39 | "eslint-plugin-jest": "^24.1.0", 40 | "eslint-plugin-jsx-a11y": "^6.3.1", 41 | "eslint-plugin-prettier": "^3.1.4", 42 | "eslint-plugin-react": "^7.0.0", 43 | "git-url-parse": "^11.3.0", 44 | "jsdom": "^16.4.0", 45 | "mocha": "^3.3.0", 46 | "prettier": "^2.1.2", 47 | "raw-loader": "^4.0.1", 48 | "react": "^15.5.4", 49 | "react-addons-test-utils": "^15.5.1", 50 | "react-dom": "^15.5.4", 51 | "sinon": "^2.2.0", 52 | "stack-source-map": "^1.0.6", 53 | "style-loader": "^1.3.0", 54 | "webpack-hot-middleware": "^2.18.0" 55 | }, 56 | "peerDependencies": { 57 | "react": "^0.14.7 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0" 58 | }, 59 | "dependencies": { 60 | "prop-types": "^15.5.9", 61 | "babel-runtime": "^6.23.0", 62 | "classnames": "^2.2.5", 63 | "fuse.js": "^3.0.1" 64 | }, 65 | "main": "dist/index.js", 66 | "config": { 67 | "commitizen": { 68 | "path": "node_modules/cz-conventional-changelog" 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import classNames from 'classnames'; 4 | import Fuse from 'fuse.js'; 5 | 6 | const styles = { 7 | searchBoxStyle: { 8 | border: '1px solid #eee', 9 | borderRadius: 2, 10 | padding: '8px 10px', 11 | lineHeight: '24px', 12 | width: '100%', 13 | outline: 'none', 14 | fontSize: 16, 15 | color: '#666', 16 | boxSizing: 'border-box', 17 | fontFamily: 'inherit', 18 | }, 19 | searchBoxWrapper: { 20 | padding: '4px', 21 | boxShadow: '0 4px 15px 4px rgba(0,0,0,0.2)', 22 | borderRadius: 2, 23 | backgroundColor: '#fff', 24 | }, 25 | resultsStyle: { 26 | backgroundColor: '#fff', 27 | position: 'relative', 28 | padding: '12px', 29 | borderTop: '1px solid #eee', 30 | color: '#666', 31 | fontSize: 14, 32 | cursor: 'pointer', 33 | }, 34 | selectedResultStyle: { 35 | backgroundColor: '#f9f9f9', 36 | position: 'relative', 37 | padding: '12px', 38 | borderTop: '1px solid #eee', 39 | color: '#666', 40 | fontSize: 14, 41 | cursor: 'pointer', 42 | }, 43 | resultsWrapperStyle: { 44 | width: '100%', 45 | boxShadow: '0px 12px 30px 2px rgba(0, 0, 0, 0.1)', 46 | border: '1px solid #eee', 47 | borderTop: 0, 48 | boxSizing: 'border-box', 49 | maxHeight: 400, 50 | overflow: 'auto', 51 | position: 'relative', 52 | }, 53 | }; 54 | 55 | function defaultResultsTemplate(props, state, styl, clickHandler) { 56 | return state.results.map((val, i) => { 57 | const style = state.selectedIndex === i ? {...styl.selectedResultStyle, ...props.selectedListItemStyle} : {...styl.resultsStyle, ...props.listItemStyle }; 58 | return ( 59 |
clickHandler(i)}> 60 | {val[props.keyForDisplayName]} 61 |
62 | ); 63 | }); 64 | } 65 | 66 | export default class FuzzySearch extends Component { 67 | static propTypes = { 68 | caseSensitive: PropTypes.bool, 69 | className: PropTypes.string, 70 | distance: PropTypes.number, 71 | id: PropTypes.string, 72 | include: PropTypes.array, 73 | inputProps: PropTypes.object, 74 | isDropdown: PropTypes.bool, 75 | maxPatternLength: PropTypes.number, 76 | onSelect: PropTypes.func.isRequired, 77 | width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), 78 | keyForDisplayName: PropTypes.string, 79 | keys: PropTypes.oneOfType([PropTypes.array, PropTypes.string]), 80 | list: PropTypes.array.isRequired, 81 | location: PropTypes.number, 82 | placeholder: PropTypes.string, 83 | resultsTemplate: PropTypes.func, 84 | shouldShowDropdownAtStart: PropTypes.bool, 85 | shouldSort: PropTypes.bool, 86 | sortFn: PropTypes.func, 87 | threshold: PropTypes.number, 88 | tokenize: PropTypes.bool, 89 | verbose: PropTypes.bool, 90 | autoFocus: PropTypes.bool, 91 | maxResults: PropTypes.number, 92 | options: PropTypes.object, 93 | inputStyle: PropTypes.object, 94 | inputWrapperStyle: PropTypes.object, 95 | listItemStyle: PropTypes.object, 96 | listWrapperStyle: PropTypes.object, 97 | selectedListItemStyle: PropTypes.object, 98 | }; 99 | 100 | static defaultProps = { 101 | caseSensitive: false, 102 | distance: 100, 103 | include: [], 104 | inputProps: {}, 105 | isDropdown: false, 106 | keyForDisplayName: 'title', 107 | location: 0, 108 | width: 430, 109 | placeholder: 'Search', 110 | resultsTemplate: defaultResultsTemplate, 111 | shouldShowDropdownAtStart: false, 112 | shouldSort: true, 113 | sortFn(a, b) { 114 | return a.score - b.score; 115 | }, 116 | threshold: 0.6, 117 | tokenize: false, 118 | verbose: false, 119 | autoFocus: false, 120 | maxResults: 10, 121 | inputStyle: {}, 122 | inputWrapperStyle: {}, 123 | listItemStyle: {}, 124 | listWrapperStyle: {}, 125 | selectedListItemStyle: {}, 126 | }; 127 | 128 | constructor(props) { 129 | super(props); 130 | this.state = { 131 | isOpen: !this.props.shouldShowDropdownAtStart, 132 | results: [], 133 | selectedIndex: 0, 134 | value: props.inputProps.defaultValue || '', 135 | }; 136 | this.handleChange = this.handleChange.bind(this); 137 | this.handleKeyDown = this.handleKeyDown.bind(this); 138 | this.handleMouseClick = this.handleMouseClick.bind(this); 139 | this.fuse = new Fuse(props.list, this.getOptions()); 140 | this.setDropdownRef = ref => { 141 | this.dropdownRef = ref; 142 | }; 143 | } 144 | 145 | getOptions() { 146 | const { 147 | caseSensitive, 148 | id, 149 | include, 150 | keys, 151 | shouldSort, 152 | sortFn, 153 | tokenize, 154 | verbose, 155 | maxPatternLength, 156 | distance, 157 | threshold, 158 | location, 159 | options, 160 | } = this.props; 161 | 162 | return { 163 | caseSensitive, 164 | id, 165 | include, 166 | keys, 167 | shouldSort, 168 | sortFn, 169 | tokenize, 170 | verbose, 171 | maxPatternLength, 172 | distance, 173 | threshold, 174 | location, 175 | ...options, 176 | }; 177 | } 178 | 179 | handleChange(e) { 180 | e.persist(); 181 | 182 | if (this.props.inputProps.onChange) { 183 | this.props.inputProps.onChange(e); 184 | } 185 | 186 | const shouldDisplayAllListItems = this.props.shouldShowDropdownAtStart && !e.target.value; 187 | 188 | this.setState({ 189 | isOpen: true, 190 | results: shouldDisplayAllListItems 191 | ? this.props.list 192 | : this.fuse.search(e.target.value).slice(0, this.props.maxResults - 1), 193 | value: e.target.value, 194 | }); 195 | } 196 | 197 | handleKeyDown(e) { 198 | const { results, selectedIndex } = this.state; 199 | 200 | // Handle DOWN arrow 201 | if (e.keyCode === 40 && selectedIndex < results.length - 1) { 202 | this.setState({ 203 | selectedIndex: selectedIndex + 1, 204 | }); 205 | 206 | // Handle UP arrow 207 | } else if (e.keyCode === 38 && selectedIndex > 0) { 208 | this.setState({ 209 | selectedIndex: selectedIndex - 1, 210 | }); 211 | 212 | // Handle ENTER 213 | } else if (e.keyCode === 13) { 214 | this.selectItem(); 215 | } 216 | } 217 | 218 | selectItem(index) { 219 | const { results } = this.state; 220 | const selectedIndex = index || this.state.selectedIndex; 221 | const result = results[selectedIndex]; 222 | if (result) { 223 | // send result to onSelectMethod 224 | this.props.onSelect(result); 225 | // and set it as input value 226 | this.setState({ 227 | value: result[this.props.keyForDisplayName], 228 | }); 229 | } 230 | // hide dropdown 231 | this.setState({ 232 | results: [], 233 | selectedIndex: 0, 234 | }); 235 | } 236 | 237 | handleMouseClick(clickedIndex) { 238 | this.selectItem(clickedIndex); 239 | } 240 | 241 | render() { 242 | const { 243 | autoFocus, 244 | className, 245 | inputProps, 246 | isDropdown, 247 | list, 248 | placeholder, 249 | resultsTemplate, 250 | shouldShowDropdownAtStart, 251 | width, 252 | } = this.props; 253 | 254 | // Update the search space list 255 | if (this.fuse.setCollection && list) { 256 | this.fuse.setCollection(list); 257 | } 258 | 259 | const mainClass = classNames('react-fuzzy-search', className); 260 | 261 | return ( 262 |
{ 267 | if (this.dropdownRef.contains(e.relatedTarget)) return; 268 | 269 | // Check shouldShowDropdownAtStart for backwards-compatibility. 270 | if (isDropdown || shouldShowDropdownAtStart) { 271 | this.setState({ 272 | isOpen: false, 273 | }); 274 | } 275 | }} 276 | onKeyDown={this.handleKeyDown} 277 | > 278 |
279 | { 288 | if (shouldShowDropdownAtStart) { 289 | this.setState({ 290 | isOpen: true, 291 | results: this.state.value ? this.state.results : list, 292 | }); 293 | } 294 | 295 | if(inputProps.onFocus) { 296 | inputProps.onFocus(e) 297 | } 298 | }} 299 | /> 300 |
301 | {this.state.isOpen && this.state.results && this.state.results.length > 0 && ( 302 |
303 | {resultsTemplate(this.props, this.state, styles, this.handleMouseClick)} 304 |
305 | )} 306 |
307 | ); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/stories/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { storiesOf, action } from '@kadira/storybook'; 3 | import FuzzySearch from '../index'; 4 | import './style.css'; 5 | 6 | const list = [ 7 | { 8 | id: 1, 9 | title: 'The Great Gatsby', 10 | author: 'F. Scott Fitzgerald', 11 | }, 12 | { 13 | id: 2, 14 | title: 'The DaVinci Code', 15 | author: 'Dan Brown', 16 | }, 17 | { 18 | id: 3, 19 | title: 'Angels & Demons', 20 | author: 'Dan Brown', 21 | }, 22 | { 23 | id: 4, 24 | title: 'The Greater Gatsby', 25 | author: 'F. Scott Fitzgerald', 26 | }, 27 | { 28 | id: 5, 29 | title: 'The DaVinci1 Code', 30 | author: 'Dan Brown', 31 | }, 32 | { 33 | id: 6, 34 | title: 'Angels1 & Demons', 35 | author: 'Dan Brown', 36 | }, 37 | { 38 | id: 7, 39 | title: 'The Greater2 Gatsby', 40 | author: 'F. Scott Fitzgerald', 41 | }, 42 | { 43 | id: 8, 44 | title: 'The DaVinci2 Code', 45 | author: 'Dan Brown', 46 | }, 47 | { 48 | id: 9, 49 | title: 'Angels2 & Demons', 50 | author: 'Dan Brown', 51 | }, 52 | { 53 | id: 10, 54 | title: 'The Greater Gatsby', 55 | author: 'F. Scott Fitzgerald', 56 | }, 57 | { 58 | id: 11, 59 | title: 'The DaVinci1 Code', 60 | author: 'Dan Brown', 61 | }, 62 | { 63 | id: 12, 64 | title: 'Angels1 & Demons', 65 | author: 'Dan Brown', 66 | }, 67 | ]; 68 | 69 | storiesOf('SearchBox', module) 70 | .add('Basic', () => ( 71 | 72 | )) 73 | .add('Dropdown behavior', () => ( 74 | 81 | )) 82 | .add('Custom Styles', () => ( 83 | 103 | )) 104 | .add('Custom Template', () => { 105 | function x(props, state, styles, clickHandler) { 106 | return state.results.map((val, i) => { 107 | const style = state.selectedIndex === i ? styles.selectedResultStyle : styles.resultsStyle; 108 | return ( 109 |
clickHandler(i)} 113 | > 114 | {val.title} 115 | by {val.author} 116 |
117 | ); 118 | }); 119 | } 120 | 121 | return ( 122 | 129 | ); 130 | }) 131 | .add('Show Dropdown at Start', () => ( 132 | 139 | )) 140 | .add('Passthrough Options', () => { 141 | const template = (props, state, styles, click) => 142 | state.results.map(({ item, matches }, i) => { 143 | const style = state.selectedIndex === i ? styles.selectedResultStyle : styles.resultsStyle; 144 | return ( 145 |
click(i)}> 146 | {item.title} 147 | by {item.author} 148 |
149 | ); 150 | }); 151 | 152 | return ( 153 | 161 | ); 162 | }); 163 | -------------------------------------------------------------------------------- /src/stories/style.css: -------------------------------------------------------------------------------- 1 | .react-fuzzy-search{ 2 | margin: 20px auto; 3 | } 4 | 5 | ::-webkit-input-placeholder { 6 | font-size: 16px; 7 | } 8 | 9 | :-moz-placeholder { /* Firefox 18- */ 10 | font-size: 16px; 11 | } 12 | 13 | ::-moz-placeholder { /* Firefox 19+ */ 14 | font-size: 16px; 15 | } 16 | 17 | :-ms-input-placeholder { 18 | font-size: 16px; 19 | } 20 | -------------------------------------------------------------------------------- /src/tests/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { mount } from 'enzyme'; 3 | import { expect } from 'chai'; 4 | import sinon from 'sinon'; 5 | 6 | import FuzzySearch from '../index'; 7 | 8 | const { describe, it } = global; 9 | 10 | const list = [ 11 | { 12 | id: 1, 13 | title: 'The Great Gatsby', 14 | author: 'F. Scott Fitzgerald', 15 | }, 16 | { 17 | id: 2, 18 | title: 'The DaVinci Code', 19 | author: 'Dan Brown', 20 | }, 21 | { 22 | id: 3, 23 | title: 'Angels & Demons', 24 | author: 'Dan Brown', 25 | }, 26 | ]; 27 | 28 | describe('', () => { 29 | it('should set correct placeholder text', () => { 30 | const onSelect = sinon.spy(); 31 | const wrapper = mount( 32 | , 38 | ); 39 | const placeholder = wrapper.find('input').prop('placeholder'); 40 | expect(placeholder).to.equal('testing'); 41 | }); 42 | 43 | it('should show results on typing', () => { 44 | const onSelect = sinon.spy(); 45 | const wrapper = mount( 46 | , 47 | ); 48 | 49 | const input = wrapper.find('input'); 50 | expect(wrapper.state('results').length).to.equal(0); 51 | 52 | input.simulate('change', { 53 | target: { 54 | value: 't', 55 | }, 56 | }); 57 | 58 | expect(wrapper.state('results').length).to.not.equal(0); 59 | }); 60 | 61 | it('should set results as ids if passed in options', () => { 62 | const onChange = sinon.spy(); 63 | const wrapper = mount( 64 | , 65 | ); 66 | 67 | const input = wrapper.find('input'); 68 | input.simulate('change', { 69 | target: { 70 | value: 't', 71 | }, 72 | }); 73 | 74 | expect(wrapper.state('results')).to.eql(['1', '2']); 75 | }); 76 | 77 | it('should call onChange on selection of result', () => { 78 | const onChange = sinon.spy(); 79 | const wrapper = mount( 80 | , 81 | ); 82 | 83 | const input = wrapper.find('input'); 84 | input.simulate('change', { 85 | target: { 86 | value: 't', 87 | }, 88 | }); 89 | 90 | expect(wrapper.state('results').length).to.not.equal(0); 91 | 92 | const div = wrapper.find('.react-fuzzy-search'); 93 | 94 | div.simulate('keydown', { 95 | keyCode: 13, 96 | }); 97 | 98 | expect(onChange.calledOnce).to.equal(true); 99 | }); 100 | 101 | it('should overwrite previous props with options passed in', () => { 102 | const onChange = sinon.spy(); 103 | const wrapper = mount( 104 | , 110 | ); 111 | 112 | const input = wrapper.find('input'); 113 | input.simulate('change', { 114 | target: { 115 | value: 't', 116 | }, 117 | }); 118 | 119 | // Each result should have a 'matches' array now with `includeMatches` 120 | expect(wrapper.state('results')[0].matches.length).to.not.equal(0); 121 | }); 122 | 123 | it('should display keyForDisplayName when passed in', () => { 124 | const onChange = sinon.spy(); 125 | const wrapper = mount( 126 | , 133 | ); 134 | 135 | const input = wrapper.find('input'); 136 | input.simulate('change', { 137 | target: { 138 | value: 'f', 139 | }, 140 | }); 141 | 142 | // Each result should have a 'matches' array now with `includeMatches` 143 | expect(wrapper.state('results')[0].item.title).to.equal('The Great Gatsby'); 144 | }); 145 | 146 | it('should support style props', () => { 147 | const onChange = sinon.spy(); 148 | const wrapper = mount( 149 | , 169 | ); 170 | 171 | expect(wrapper.find('input')).to.exist; 172 | }) 173 | 174 | it('should display all options onFocus when shouldShowDropdownAtStart passed in', () => { 175 | const onChange = sinon.spy(); 176 | const wrapper = mount( 177 | , 185 | ); 186 | 187 | const input = wrapper.find('input'); 188 | 189 | input.simulate('focus'); 190 | 191 | expect(wrapper.state('results').length).to.not.equal(0); 192 | }); 193 | 194 | it('with inputProps provided', () => { 195 | const onChange = sinon.spy(); 196 | const wrapper = mount( 197 | , 208 | ); 209 | 210 | const input = wrapper.find('input'); 211 | 212 | expect(input.get(0).value).to.equal('Hello there!'); 213 | 214 | input.simulate('change', { 215 | target: { 216 | value: 't', 217 | }, 218 | }); 219 | 220 | expect(onChange.calledOnce).to.equal(true); 221 | }); 222 | }); 223 | --------------------------------------------------------------------------------