├── .babelrc ├── .editorconfig ├── .gitignore ├── .travis.yml ├── Makefile ├── README.md ├── example ├── index.html └── src │ ├── app.js │ └── mails.js ├── package.json ├── react-search-input.css └── src ├── __tests__ ├── getValuesForKeys.test.js └── searchStrings.test.js ├── index.js └── util.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["es2015", "stage-0", "react"] 3 | } 4 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pids 2 | logs 3 | npm-debug.log 4 | node_modules 5 | example/lib 6 | example/app.bundle.js 7 | /lib 8 | coverage 9 | .vscode/ 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "4" 4 | - "5" 5 | - "6" 6 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | BIN=node_modules/.bin 2 | 3 | build: 4 | $(BIN)/babel src --out-dir lib && \ 5 | $(BIN)/babel example/src --out-dir example/lib && \ 6 | $(BIN)/webpack example/lib/app.js example/app.bundle.js -p 7 | 8 | clean: 9 | rm -rf lib && rm -rf example/lib && rm -f example/app.bundle.js 10 | 11 | PHONY: build clean 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-search-input 2 | [![build status](https://img.shields.io/travis/enkidevs/react-search-input/master.svg?style=flat-square)](https://travis-ci.org/enkidevs/react-search-input) 3 | [![npm version](https://img.shields.io/npm/v/react-search-input.svg?style=flat-square)](https://www.npmjs.com/package/react-search-input) 4 | [![Dependency Status](https://david-dm.org/enkidevs/react-search-input.svg)](https://david-dm.org/enkidevs/react-search-input) 5 | [![devDependency Status](https://david-dm.org/enkidevs/react-search-input/dev-status.svg)](https://david-dm.org/enkidevs/react-search-input#info=devDependencies) 6 | 7 | > Simple [React](http://facebook.github.io/react/index.html) component for a search input, providing a filter function. 8 | 9 | ### [Demo](https://enkidevs.github.io/react-search-input) 10 | 11 | ## Install 12 | 13 | ```bash 14 | npm install react-search-input --save 15 | ``` 16 | 17 | ## Example 18 | 19 | ```javascript 20 | import React, {Component} from 'react' 21 | import SearchInput, {createFilter} from 'react-search-input' 22 | 23 | import emails from './mails' 24 | 25 | const KEYS_TO_FILTERS = ['user.name', 'subject', 'dest.name'] 26 | 27 | class App extends Component { 28 | constructor (props) { 29 | super(props) 30 | this.state = { 31 | searchTerm: '' 32 | } 33 | this.searchUpdated = this.searchUpdated.bind(this) 34 | } 35 | 36 | render () { 37 | const filteredEmails = emails.filter(createFilter(this.state.searchTerm, KEYS_TO_FILTERS)) 38 | 39 | return ( 40 |
41 | 42 | {filteredEmails.map(email => { 43 | return ( 44 |
45 |
{email.user.name}
46 |
{email.subject}
47 |
48 | ) 49 | })} 50 |
51 | ) 52 | } 53 | 54 | searchUpdated (term) { 55 | this.setState({searchTerm: term}) 56 | } 57 | } 58 | 59 | ``` 60 | 61 | ## API 62 | 63 | ### Props 64 | 65 | All props are optional. All other props will be passed to the DOM input. 66 | 67 | ##### className 68 | 69 | Class of the Component (in addition of `search-input`). 70 | 71 | ##### onChange 72 | 73 | Function called when the search term is changed (will be passed as an argument). 74 | 75 | ##### filterKeys 76 | 77 | Either an `[String]` or a `String`. Will be use by the `filter` method if no argument is passed there. 78 | 79 | ##### throttle 80 | 81 | Reduce call frequency to the `onChange` function (in ms). Default is `200`. 82 | 83 | ##### caseSensitive 84 | 85 | Define if the search should be case sensitive. Default is `false` 86 | 87 | ##### fuzzy 88 | 89 | Define if the search should be fuzzy. Default is `false` 90 | 91 | ##### sortResults 92 | 93 | Define if search results should be sorted by relevance (only works with fuzzy search). Default is `false` 94 | 95 | ##### value 96 | 97 | Define the value of the input. 98 | 99 | ### Methods 100 | 101 | ##### filter([keys]) 102 | 103 | Return a function which can be used to filter an array. `keys` can be `String`, `[String]` or `null`. 104 | 105 | If an array `keys` is an array, the function will return true if at least one of the keys of the item matches the search term. 106 | 107 | ### Static Methods 108 | 109 | ##### filter(searchTerm, [keys], [{caseSensitive, fuzzy, sortResults}]) 110 | 111 | Return a function which can be used to filter an array. `searchTerm` can be a `regex` or a `String`. `keys` can be `String`, `[String]` or `null`. 112 | 113 | If an array `keys` is an array, the function will return true if at least one of the keys of the item matches the search term. 114 | 115 | ## Styles 116 | 117 | Look at [react-search-input.css](react-search-input.css) for an idea on how to style this component. 118 | 119 | --- 120 | 121 | MIT Licensed 122 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | react-search-input example 5 | 6 | 36 | 37 | 38 | 39 |
40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /example/src/app.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import {render} from 'react-dom' 3 | 4 | import SearchInput, {createFilter} from '../../lib/index' 5 | 6 | import emails from './mails' 7 | 8 | const KEYS_TO_FILTERS = ['user.name', 'subject', 'dest.name', 'id'] 9 | 10 | class App extends Component { 11 | constructor (props) { 12 | super(props) 13 | this.state = { 14 | searchTerm: '' 15 | } 16 | this.searchUpdated = this.searchUpdated.bind(this) 17 | } 18 | 19 | render () { 20 | const filteredEmails = emails.filter(createFilter(this.state.searchTerm, KEYS_TO_FILTERS)) 21 | 22 | return ( 23 |
24 | 25 | {filteredEmails.map(email => { 26 | return ( 27 |
28 |
{email.user.name}
29 |
{email.subject}
30 |
31 | ) 32 | })} 33 |
34 | ) 35 | } 36 | 37 | searchUpdated (term) { 38 | this.setState({searchTerm: term}) 39 | } 40 | } 41 | 42 | render(, document.getElementById('app')) 43 | -------------------------------------------------------------------------------- /example/src/mails.js: -------------------------------------------------------------------------------- 1 | export default [{ 2 | id: 1, 3 | user: { 4 | name: 'Mathieu', 5 | job: 'Software Engineer', 6 | company: 'Enki' 7 | }, 8 | subject: 'Hi!', 9 | dest: [ 10 | { 11 | name: 'Bruno', 12 | job: 'CTO', 13 | company: 'Enki' 14 | }, 15 | { 16 | name: 'Arseny', 17 | job: 'Software Engineer', 18 | company: 'Enki' 19 | } 20 | ] 21 | }, { 22 | id: 2, 23 | user: { 24 | name: 'Bruno' 25 | }, 26 | subject: 'javascript', 27 | dest: [ 28 | { 29 | name: 'Mathieu', 30 | job: 'CTO', 31 | company: 'Enki' 32 | }, 33 | { 34 | name: 'Arseny', 35 | job: 'Software Engineer', 36 | company: 'Enki' 37 | } 38 | ] 39 | }, { 40 | id: 3, 41 | user: { 42 | name: 'you can search for me using a regex : `java$`' 43 | }, 44 | subject: 'java', 45 | dest: [ 46 | { 47 | name: 'Bruno', 48 | job: 'CTO', 49 | company: 'Enki' 50 | }, 51 | { 52 | name: 'Arseny', 53 | job: 'Software Engineer', 54 | company: 'Enki' 55 | } 56 | ] 57 | }] 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-search-input", 3 | "version": "0.11.3", 4 | "description": "Simple react.js component for a search input, providing a filter function.", 5 | "main": "lib/index.js", 6 | "files": [ 7 | "lib" 8 | ], 9 | "devDependencies": { 10 | "babel-cli": "^6.8.0", 11 | "babel-jest": "^19.0.0", 12 | "babel-preset-es2015": "^6.6.0", 13 | "babel-preset-react": "^6.5.0", 14 | "babel-preset-stage-0": "^6.5.0", 15 | "immutable": "^3.8.1", 16 | "jest": "^19.0.2", 17 | "react": "^15.1.0", 18 | "react-dom": "^15.1.0", 19 | "standard": "^10.0.2", 20 | "webpack": "^2.2.1" 21 | }, 22 | "scripts": { 23 | "test": "standard && jest", 24 | "prepublish": "make clean build" 25 | }, 26 | "repository": { 27 | "type": "git", 28 | "url": "https://github.com/enkidevs/react-search-input" 29 | }, 30 | "keywords": [ 31 | "react", 32 | "search", 33 | "input", 34 | "filter", 35 | "component", 36 | "javascript", 37 | "react-component" 38 | ], 39 | "author": "Mathieu Dutour ", 40 | "license": "MIT", 41 | "bugs": { 42 | "url": "https://github.com/enkidevs/react-search-input/issues" 43 | }, 44 | "homepage": "https://github.com/enkidevs/react-search-input", 45 | "dependencies": { 46 | "fuse.js": "^3.0.0", 47 | "prop-types": "^15.5.8" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /react-search-input.css: -------------------------------------------------------------------------------- 1 | .search-input { 2 | padding: 10px 10px; 3 | height: 52px; 4 | position: relative; 5 | } 6 | 7 | .search-input::before { 8 | content: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAPCAYAAAA71pVKAAAAAXNSR0IArs4c6QAAAQJJREFUKBWVkr2uQUEUhf3ET6GRaC5aFRoJKrf1BKpb8SwqovYGXkCj00k0QnRKEYkILYobvpUYmeMMyVnJl7P3mjN7Zu9zwiGv2qRFyMMSRrAFp6JPN8XzBj+wgDkUYAg7WINTYdwpDECxrRLJHeq2accdkgm8bzTvNAg2EDOGeUYI1KNO1gkuzTA1g8T7ojbn4ONQWPuHPWgeHmnzCqoe15tkSNPgPEAn68oVcOmA2XMtGK9FoE/VhOTTVNExqLCGZnxCv2pYauEC6lF0oQxX6IOvb7yX9NPEQafan+aPXDdQC18LsO6Tip5BBY6gIQaSbnMCFRCBZRcIvFkbsvCr4AFGOCxQy+JdGQAAAABJRU5ErkJggg=='); 9 | display: block; 10 | position: absolute; 11 | width: 15px; 12 | z-index: 3; 13 | height: 15px; 14 | font-size: 20px; 15 | top: 11px; 16 | left: 16px; 17 | line-height: 32px; 18 | opacity: 0.6; 19 | } 20 | 21 | .search-input > input { 22 | width: 100%; 23 | font-size: 18px; 24 | border: none; 25 | line-height: 22px; 26 | padding: 5px 10px 5px 25px; 27 | height: 32px; 28 | position: relative; 29 | } 30 | 31 | .search-input > input:focus { 32 | outline: none; 33 | } 34 | -------------------------------------------------------------------------------- /src/__tests__/getValuesForKeys.test.js: -------------------------------------------------------------------------------- 1 | /* globals test expect */ 2 | import { Map } from 'immutable' 3 | import { getValuesForKey } from '../util' 4 | 5 | test('should get the values to search on in an object', () => { 6 | const value = getValuesForKey('foo', { 7 | foo: 'bar' 8 | }) 9 | expect(value).toEqual(['bar']) 10 | }) 11 | 12 | test('should get the values to search on in an array', () => { 13 | const value = getValuesForKey('foo', [{ 14 | foo: 'bar' 15 | }]) 16 | expect(value).toEqual(['bar']) 17 | }) 18 | 19 | test('should get the values to search on in a nested object', () => { 20 | const value = getValuesForKey('foo.bar', { 21 | foo: { 22 | bar: 'baz' 23 | } 24 | }) 25 | expect(value).toEqual(['baz']) 26 | }) 27 | 28 | test('should get the values to search on in a nested array', () => { 29 | const value = getValuesForKey('foo', { 30 | foo: ['bar', 'baz'] 31 | }) 32 | expect(value).toEqual(['bar', 'baz']) 33 | }) 34 | 35 | test('should get the values to search on in a nested array', () => { 36 | const value = getValuesForKey('foo.bar', { 37 | foo: [{ 38 | bar: 'baz' 39 | }, { 40 | bar: 'baz2' 41 | }] 42 | }) 43 | expect(value).toEqual(['baz', 'baz2']) 44 | }) 45 | 46 | test('should get the values to search on in a nested array with an index', () => { 47 | const value = getValuesForKey('foo.1.bar', { 48 | foo: [{ 49 | bar: 'baz' 50 | }, { 51 | bar: 'baz2' 52 | }] 53 | }) 54 | expect(value).toEqual(['baz2']) 55 | }) 56 | 57 | test('should ignore undefined values', () => { 58 | const value = getValuesForKey('fooz', { 59 | foo: [{ 60 | bar: 'baz' 61 | }, { 62 | bar: 'baz2' 63 | }] 64 | }) 65 | expect(value).toEqual([]) 66 | }) 67 | 68 | test('should get the values to search on in an immutable map', () => { 69 | const value = getValuesForKey('foo.bar', Map({ 70 | foo: { 71 | bar: 'baz' 72 | } 73 | })) 74 | expect(value).toEqual(['baz']) 75 | }) 76 | 77 | test('should ignore non-string and non-number values', () => { 78 | const value = getValuesForKey('foo.bar', { 79 | foo: [{ 80 | bar: [] 81 | }, { 82 | bar: [] 83 | }] 84 | }) 85 | expect(value).toEqual([]) 86 | }) 87 | -------------------------------------------------------------------------------- /src/__tests__/searchStrings.test.js: -------------------------------------------------------------------------------- 1 | /* globals test expect */ 2 | import { searchStrings } from '../util' 3 | 4 | test('should return true if the term is in the strings', () => { 5 | const res = searchStrings(['foobar', 'bar'], 'foo') 6 | expect(res).toBe(true) 7 | }) 8 | 9 | test('should return false if the term isn\'t in the strings', () => { 10 | const res = searchStrings(['barbaz', 'bar'], 'foo') 11 | expect(res).toBe(false) 12 | }) 13 | 14 | test('should return false if the term is in the strings but doesn\'t have the right case', () => { 15 | const res = searchStrings(['foobaz', 'bar'], 'Foo') 16 | expect(res).toBe(false) 17 | }) 18 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react' 2 | import PropTypes from 'prop-types' 3 | import {createFilter} from './util' 4 | 5 | class Search extends Component { 6 | constructor (props) { 7 | super(props) 8 | this.state = { 9 | searchTerm: this.props.value || '' 10 | } 11 | this.updateSearch = this.updateSearch.bind(this) 12 | this.filter = this.filter.bind(this) 13 | } 14 | 15 | componentWillReceiveProps (nextProps) { 16 | if ( 17 | typeof nextProps.value !== 'undefined' && 18 | nextProps.value !== this.props.value 19 | ) { 20 | const e = { 21 | target: { 22 | value: nextProps.value 23 | } 24 | } 25 | this.updateSearch(e) 26 | } 27 | } 28 | 29 | render () { 30 | const { 31 | className, 32 | onChange, 33 | caseSensitive, 34 | sortResults, 35 | throttle, 36 | filterKeys, 37 | value, 38 | fuzzy, 39 | inputClassName, 40 | ...inputProps 41 | } = this.props // eslint-disable-line no-unused-vars 42 | inputProps.type = inputProps.type || 'search' 43 | inputProps.value = this.state.searchTerm 44 | inputProps.onChange = this.updateSearch 45 | inputProps.className = inputClassName 46 | inputProps.placeholder = inputProps.placeholder || 'Search' 47 | return ( 48 |
49 | 50 |
51 | ) 52 | } 53 | 54 | updateSearch (e) { 55 | const searchTerm = e.target.value 56 | this.setState( 57 | { 58 | searchTerm: searchTerm 59 | }, 60 | () => { 61 | if (this._throttleTimeout) { 62 | clearTimeout(this._throttleTimeout) 63 | } 64 | 65 | this._throttleTimeout = setTimeout( 66 | () => this.props.onChange(searchTerm), 67 | this.props.throttle 68 | ) 69 | } 70 | ) 71 | } 72 | 73 | filter (keys) { 74 | const {filterKeys, caseSensitive, fuzzy, sortResults} = this.props 75 | return createFilter(this.state.searchTerm, keys || filterKeys, { 76 | caseSensitive, 77 | fuzzy, 78 | sortResults 79 | }) 80 | } 81 | } 82 | 83 | Search.defaultProps = { 84 | className: '', 85 | onChange () {}, 86 | caseSensitive: false, 87 | fuzzy: false, 88 | throttle: 200 89 | } 90 | 91 | Search.propTypes = { 92 | className: PropTypes.string, 93 | inputClassName: PropTypes.string, 94 | onChange: PropTypes.func, 95 | caseSensitive: PropTypes.bool, 96 | sortResults: PropTypes.bool, 97 | fuzzy: PropTypes.bool, 98 | throttle: PropTypes.number, 99 | filterKeys: PropTypes.oneOf([ 100 | PropTypes.string, 101 | PropTypes.arrayOf(PropTypes.string) 102 | ]), 103 | value: PropTypes.string 104 | } 105 | 106 | export default Search 107 | export {createFilter} 108 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | import Fuse from 'fuse.js' 2 | 3 | function flatten (array) { 4 | return array.reduce((flat, toFlatten) => ( 5 | flat.concat(Array.isArray(toFlatten) ? flatten(toFlatten) : toFlatten) 6 | ), []) 7 | } 8 | 9 | export function getValuesForKey (key, item) { 10 | const keys = key.split('.') 11 | let results = [item] 12 | keys.forEach(_key => { 13 | let tmp = [] 14 | results.forEach(result => { 15 | if (result) { 16 | if (result instanceof Array) { 17 | const index = parseInt(_key, 10) 18 | if (!isNaN(index)) { 19 | return tmp.push(result[index]) 20 | } 21 | result.forEach(res => { 22 | tmp.push(res[_key]) 23 | }) 24 | } else if (result && typeof result.get === 'function') { 25 | tmp.push(result.get(_key)) 26 | } else { 27 | tmp.push(result[_key]) 28 | } 29 | } 30 | }) 31 | 32 | results = tmp 33 | }) 34 | 35 | // Support arrays and Immutable lists. 36 | results = results.map(r => (r && r.push && r.toArray) ? r.toArray() : r) 37 | results = flatten(results) 38 | 39 | return results.filter(r => typeof r === 'string' || typeof r === 'number') 40 | } 41 | 42 | export function searchStrings (strings, term, {caseSensitive, fuzzy, sortResults, exactMatch} = {}) { 43 | strings = strings.map(e => e.toString()) 44 | 45 | try { 46 | if (fuzzy) { 47 | if (typeof strings.toJS === 'function') { 48 | strings = strings.toJS() 49 | } 50 | const fuse = new Fuse( 51 | strings.map(s => { return {id: s} }), 52 | { keys: ['id'], id: 'id', caseSensitive, shouldSort: sortResults } 53 | ) 54 | return fuse.search(term).length 55 | } 56 | return strings.some(value => { 57 | try { 58 | if (!caseSensitive) { 59 | value = value.toLowerCase() 60 | } 61 | if (exactMatch) { 62 | term = new RegExp('^' + term + '$', 'i') 63 | } 64 | if (value && value.search(term) !== -1) { 65 | return true 66 | } 67 | return false 68 | } catch (e) { 69 | return false 70 | } 71 | }) 72 | } catch (e) { 73 | return false 74 | } 75 | } 76 | 77 | export function createFilter (term, keys, options = {}) { 78 | return (item) => { 79 | if (term === '') { return true } 80 | 81 | if (!options.caseSensitive) { 82 | term = term.toLowerCase() 83 | } 84 | 85 | const terms = term.split(' ') 86 | 87 | if (!keys) { 88 | return terms.every(term => searchStrings([item], term, options)) 89 | } 90 | 91 | if (typeof keys === 'string') { 92 | keys = [keys] 93 | } 94 | 95 | return terms.every(term => { 96 | // allow search in specific fields with the syntax `field:search` 97 | let currentKeys 98 | if (term.indexOf(':') !== -1) { 99 | const searchedField = term.split(':')[0] 100 | term = term.split(':')[1] 101 | currentKeys = keys.filter(key => key.toLowerCase().indexOf(searchedField) > -1) 102 | } else { 103 | currentKeys = keys 104 | } 105 | 106 | return currentKeys.some(key => { 107 | const values = getValuesForKey(key, item) 108 | return searchStrings(values, term, options) 109 | }) 110 | }) 111 | } 112 | } 113 | --------------------------------------------------------------------------------