├── .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 | [](https://travis-ci.org/enkidevs/react-search-input)
3 | [](https://www.npmjs.com/package/react-search-input)
4 | [](https://david-dm.org/enkidevs/react-search-input)
5 | [](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 |
--------------------------------------------------------------------------------