├── .gitignore
├── build
├── bundle.js
└── bundle.js.map
├── examples
├── app.js
├── index.html
├── main.js
├── main.js.map
└── webpack.config.js
├── karma.conf.js
├── license
├── package.json
├── readme.md
├── src
├── components
│ └── FuzzySearch.js
├── css
│ └── fuzzy-search.css
├── index.js
├── utils
│ ├── JaroWinkler.js
│ └── containsNode.js
└── worker
│ └── worker.js
├── tests.webpack.js
├── tests
├── data.js
└── search-test.js
└── webpack.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
--------------------------------------------------------------------------------
/examples/app.js:
--------------------------------------------------------------------------------
1 | var React = require("react"),
2 | FuzzySearch = require("../src/"),
3 | _someData = require("../tests/data")
4 |
5 | var TestComponent = React.createClass({
6 | getInitialState: function(){
7 | return {
8 | selected: null
9 | }
10 | },
11 |
12 | render: function(){
13 | return (
14 |
15 |
25 |
26 | { this.state.selected &&
27 |
28 | { this.state.selected.n } was selected
29 |
30 | }
31 |
32 | );
33 | },
34 |
35 | onSearchChange: function(selected){
36 | this.setState({ selected })
37 | }
38 | })
39 |
40 | React.render( , document.getElementById("content"))
--------------------------------------------------------------------------------
/examples/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | React Fuzzy Search
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/examples/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require("path")
2 | var webpack = require("webpack")
3 |
4 | var config = {
5 | entry: './examples/app.js',
6 | output: {
7 | filename: 'examples/main.js',
8 | publicPath: ""
9 | },
10 | module: {
11 | loaders: [
12 | { test: /\.js?$/, exclude: /node_modules/, loader: 'babel-loader' },
13 | ]
14 | },
15 | plugins: [ ]
16 | };
17 |
18 | if(process.env.NODE_ENV === 'production') {
19 | config.plugins = config.plugins.concat([
20 | new webpack.DefinePlugin({
21 | "process.env": {
22 | NODE_ENV: JSON.stringify("production")
23 | }
24 | }),
25 | new webpack.optimize.DedupePlugin(),
26 | new webpack.optimize.UglifyJsPlugin(),
27 | new webpack.optimize.OccurenceOrderPlugin()
28 | ]);
29 | }
30 | else {
31 | config.devtool = 'eval';
32 | config.debug = true;
33 | }
34 |
35 | module.exports = config;
36 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | /*
2 | Credit to http://qiita.com/kimagure/items/f2d8d53504e922fe3c5c and React Router
3 | */
4 |
5 | var webpack = require('webpack');
6 |
7 | module.exports = function (config) {
8 | config.set({
9 | browsers: [ 'Chrome' ],
10 | singleRun: true,
11 | frameworks: [ 'mocha' ],
12 | files: [
13 | 'tests.webpack.js'
14 | ],
15 | preprocessors: {
16 | 'tests.webpack.js': [ 'webpack', 'sourcemap' ]
17 | },
18 | reporters: [ 'dots' ],
19 | webpack: {
20 | devtool: 'inline-source-map',
21 | module: {
22 | loaders: [
23 | { test: /\.js$/, loader: 'babel-loader' }
24 | ]
25 | }
26 | },
27 | webpackServer: {
28 | noInfo: false
29 | }
30 | });
31 | };
--------------------------------------------------------------------------------
/license:
--------------------------------------------------------------------------------
1 | (The MIT License)
2 |
3 | Copyright (c) 2015 zsutton
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the 'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-fuzzy-search",
3 | "version": "0.4.0",
4 | "description": "A fuzzy search input",
5 | "main": "./src/index",
6 | "dependencies": {
7 | "classnames": "^1.1.4",
8 | "priorityqueuejs": "^1.0.0",
9 | "react": "^0.13.2"
10 | },
11 | "devDependencies": {
12 | "babel-core": "^5.1.11",
13 | "babel-loader": "^5.0.0",
14 | "browserify": "^9.0.3",
15 | "expect": "^1.6.0",
16 | "karma": "^0.12.31",
17 | "karma-chrome-launcher": "^0.1.8",
18 | "karma-cli": "0.0.4",
19 | "karma-mocha": "^0.1.10",
20 | "karma-sourcemap-loader": "^0.3.4",
21 | "karma-webpack": "^1.5.0",
22 | "mocha": "^2.2.4",
23 | "vinyl-source-stream": "^1.0.0",
24 | "webpack": "^1.8.9"
25 | },
26 | "scripts": {
27 | "test": "karma start --single-run=false",
28 | "examples": "webpack --w --config ./examples/webpack.config.js --progress"
29 | },
30 | "repository": {
31 | "type": "git",
32 | "url": "https://github.com/zsutton/react-fuzzy-search.git"
33 | },
34 | "keywords": [
35 | "search",
36 | "react",
37 | "react-component"
38 | ],
39 | "author": "Zac Sutton",
40 | "license": "MIT",
41 | "bugs": {
42 | "url": "https://github.com/zsutton/react-fuzzy-search/issues"
43 | },
44 | "homepage": "https://github.com/zsutton/react-fuzzy-search"
45 | }
46 |
--------------------------------------------------------------------------------
/readme.md:
--------------------------------------------------------------------------------
1 | React Fuzzy Search
2 | ==================
3 |
4 | About
5 | -----
6 |
7 | An approximate search component. FuzzySearch allows users to search data using a best-match searching algorithm that allows for misspellings, typos and out-of-order search terms. For example, given a dataset with "March 16th Safety and Awareness Training, Room 217" a search for "Saferty Training 217" (note the typo) will rate it as a top result (assuming no better matches).
8 |
9 | ## Installation
10 |
11 | ```sh
12 | npm install react-fuzzy-search
13 | ```
14 |
15 | #### Installation Note
16 |
17 | This component is written using CommonJS modules. Unfortunately, both Browserify and Webpack have different methods for using CommonJS in web workers so I've opted to instead include requirements directly in the worker.js file and load it as a blob.
18 |
19 | That means no additional configuration of a path to the worker.js file. There is, however, a current incompatibility with web workers due to this at the moment that I hope to resolve (see support notes).
20 |
21 |
22 |
23 | ## Usage Notes
24 |
25 | ```javascript
26 | var FuzzySearch = require("react-fuzzy-search")
27 |
28 | ...
29 |
30 | // Note: starred props are required.
31 |
32 |
52 |
53 | ```
54 |
55 | ####More on usage
56 |
57 | React Fuzzy Search requires four props.
58 |
59 | * items {Array|Immutable List} An array of objects.
60 | * idField {*} A (unique) value for each object in the items array. This field is used as the key.
61 | * nameField {String} The name that will be displayed as a result
62 | * searchField {String} The name of the property in each item of the items array to be searched.
63 |
64 | Given an array of people such as this
65 |
66 | ```javascript
67 |
68 | var people = [{ _id: 1, name: "Bob Davis" }, { _id: 2, name: "John Thomas" }, ...]
69 |
70 | ```
71 |
72 | One could use React Fuzzy Search like this
73 |
74 | ```javascript
75 |
76 |
82 |
83 | ```
84 |
85 |
86 | ### Special Note on Immutability
87 | In order to be performant over large data sets, React Fuzzy Search does some precomputations on its data. React Fuzzy Search will detect a change in data and re-compute the search data, but in order to do so the items prop must fail an equality check.
88 |
89 | If it's not clear what that means: many array methods will update the array they are called on, changing it in place. For example, _push_, _pop_, _shift_, _unshift_, _splice_ and _sort_ all result in changes to the original array. Some methods, _slice_ and _concat_ notably, will create a new array leaving the array they were called on unchanged. And so,
90 |
91 | ```javascript
92 | var items = ["Bob", "Dave", "Sally"]
93 |
94 | items[1] = "David" // Bad: this.props.items == nextProps.items => true
95 | items.push("Amy") // Also bad: this.props.items == nextProps.items => true
96 |
97 | items = items.concat("Andy") // Good: this.props.items == nextProps.items => false
98 | items = items.slice(0) // Good: creates a copy
99 | items[1] = "David" // Can now update it: this.props.items == nextProps.items => false
100 | ```
101 |
102 | If you need to make updates be sure that you use a method that creates a new array. Or use _slice(0)_ to set your data to a new copy before updating state. An even simpler way would be to use [Immutable.js](https://github.com/facebook/immutable-js).
103 |
104 | Alternatively, immutable can be set to false and items can be updated in place. I haven't tested performance in this case but it's possible search is still performant for small datasets.
105 |
106 | ## Support
107 | Chrome, Firefox, IE>=8.
108 |
109 | #### Web worker support
110 | Due to IE10 throwing a SecurityError when using creating a web worker with a blob, they are disabled IE <11. This will be fixed in a future patch.
--------------------------------------------------------------------------------
/src/components/FuzzySearch.js:
--------------------------------------------------------------------------------
1 | var React = require("react");
2 | var PriorityQueue = require('priorityqueuejs');
3 | var cx = require("classnames")
4 | var containsNode = require("../utils/containsNode")
5 | var JaroWinkler = require("../utils/JaroWinkler")
6 |
7 | var SearchWorker = require("../worker/worker")
8 |
9 | // temporarily disabling IE10
10 | var _canUseWorkers = !!window.Worker && !/MSIE/i.test(navigator.userAgent);
11 |
12 | var punctuationRE = /[^\w ]/g
13 |
14 | function extend(dest, src){
15 | for(var p in src)
16 | dest[p] = src[p];
17 | return dest;
18 | }
19 |
20 | function computeSearchValues(items, opts){
21 | var { field, searchField, delim, immutable, removePunctuation, useWebWorkers, searchLowerCase, threadCount } = opts;
22 |
23 | searchField = searchField || field;
24 |
25 | var _searchItems = [],
26 | slices = [];
27 |
28 | items = typeof items.toArray == "function" ?
29 | items.toArray() :
30 | items;
31 |
32 | items.forEach(function(item){
33 | var _searchValues = [],
34 | added = {};
35 | _searchItems.push({ _originalItem: item, _searchValues })
36 |
37 | var curSearchField = Object.prototype.toString.call(searchField) === '[object Array]' ?
38 | searchField.reduce((acc, f) => acc + item[f] + " ", "") :
39 | item[searchField]
40 |
41 | if(removePunctuation)
42 | curSearchField = curSearchField.replace(punctuationRE, "")
43 |
44 | curSearchField.split(delim).forEach(function(term){
45 | var _term = searchLowerCase ? term.toLowerCase() : term
46 |
47 | /**
48 | Track if the field has multiples of the same word and ignore them if so
49 | */
50 | if(!added[_term]){
51 | _searchValues.push(_term)
52 | added[_term] = true
53 | }
54 | })
55 | })
56 |
57 | /**
58 | If we're using web workers split them computed search data into equal chunks per thread
59 | */
60 | if(useWebWorkers && immutable){
61 | for(var i = 0; i < threadCount; i++){
62 | var start = Math.floor(i * (_searchItems.length / threadCount)),
63 | end = Math.floor((i + 1) * (_searchItems.length / threadCount))
64 | if(i < 3)
65 | slices.push(_searchItems.slice(start, end))
66 | else
67 | slices.push(_searchItems.slice(start))
68 | }
69 | }
70 |
71 | return {
72 | computing: false,
73 | items: _searchItems,
74 | slices: slices
75 | }
76 | }
77 |
78 | var FuzzySearchResult = React.createClass({
79 | render: function(){
80 | var classes = cx("fuzzy-search-result", {
81 | "fuzzy-search-result-highlighted": this.props.highlighted
82 | })
83 |
84 | return (
85 |
86 |
87 |
88 | { this.props.item[this.props.nameField] + (this.props.showScore ? this.props.score : '' ) }
89 |
90 |
91 |
92 | );
93 | },
94 |
95 | select: function (e) {
96 | this.props.selectItem(this.props.item)
97 |
98 | e.stopPropagation();
99 | }
100 | });
101 |
102 | var FuzzySearch = React.createClass({
103 | getInitialState: function(){
104 | return {
105 | active: false,
106 | computing: true,
107 | results: [],
108 | searchTerm: "",
109 | highlightedIdx: -1,
110 | threadID: 0,
111 | threadResults: {}
112 | }
113 | },
114 |
115 | componentDidMount: function(){
116 | this._computeData()
117 |
118 | if(this.props.initialSelectedID){
119 | var selectedItem = this.props.items.filter(function (item) { return item[this.props.idField] == this.props.initialSelectedID}.bind(this))
120 | if(selectedItem.length === 1)
121 | this.setState({ selectedItem: selectedItem[0] });
122 | }
123 | },
124 |
125 | getDefaultProps: function(){
126 | return {
127 | containerClassName: "",
128 | delim: " ",
129 | immutable: true,
130 | maxItems: 25,
131 | minScore: .7,
132 | resultsComponent: FuzzySearchResult,
133 | resultsComponentProps: {},
134 | searchLowerCase: true,
135 | threadCount: 2,
136 | useWebWorkers: !!window.Worker
137 | }
138 | },
139 |
140 | componentDidUpdate: function(prevProps, prevState){
141 | if(this.props.items != prevProps.items){
142 | this.setState({
143 | computing: true
144 | }, this._computeData)
145 | }
146 | else{
147 | if(this.state.active != prevState.active){
148 | if(this.state.active){
149 | document.addEventListener("click", this._checkForClose, false)
150 | this.refs.input.getDOMNode().focus();
151 | }
152 | else
153 | document.removeEventListener("click", this._checkForClose)
154 | }
155 |
156 | if(this.state.searchingAsync && this._asyncSearchComplete()){
157 | /*
158 | Minimum score is a value [0,1] multiplied by the number of search terms. If
159 | there are 3 search terms and the minScore is .7 a result's score would need
160 | to be at least 2.1
161 | */
162 | var minScore = this.props.minScore * this.getSearchTerms().length;
163 |
164 | var results = this.state.threadResults[this.state.threadID]
165 | .reduce(function(acc, res) { return acc.concat(res) }, [])
166 | .filter(function(res) { return res._score > minScore })
167 | .sort(function(a,b) { return b._score - a._score })
168 |
169 | this.setState({
170 | results: results.slice(0, this.props.maxItems),
171 | searchingAsync: false
172 | })
173 | }
174 | }
175 | },
176 |
177 | componentWillUnmount: function(){
178 | this._closeWorkers();
179 | document.removeEventListener("click", this._checkForClose)
180 | },
181 |
182 | _checkForClose: function (e) {
183 | if(!containsNode(this.getDOMNode(), e.target))
184 | this.setInactive();
185 | },
186 |
187 | _asyncSearchComplete: function(){
188 | // check if all threads have returned a result
189 | return this.state.threadResults[this.state.threadID].length == this.props.threadCount
190 | },
191 |
192 | _computeData: function(){
193 | this.setState(computeSearchValues(this.props.items, this.props), this._createWorkers)
194 | },
195 |
196 | _createWorkers: function(){
197 | if(this.props.useWebWorkers && _canUseWorkers){
198 | this._closeWorkers();
199 | this._threads = [];
200 |
201 | var workerBlob;
202 | try{
203 | workerBlob = new Blob(['(' + SearchWorker.toString() + ')();'], {type: "text/javascript"});
204 | }
205 | catch(e){
206 | var BlobBuilder = window.BlobBuilder || window.WebKitBlobBuilder || window.MozBlobBuilder;
207 | blob = new BlobBuilder();
208 | blob.append(SearchWorker.toString());
209 | workerBlob = blob.getBlob();
210 | }
211 |
212 | var workerBlobURL = window.URL.createObjectURL(workerBlob);
213 |
214 | for(var i = 0; i < this.props.threadCount; i++){
215 | var worker;
216 | try{
217 | worker = new Worker(workerBlobURL);
218 |
219 | worker.onmessage = this.onWorkerMessage;
220 |
221 | worker.postMessage({
222 | cmd: "setData",
223 | items: this.state.slices[i]
224 | })
225 |
226 | this._threads.push({ worker })
227 | }
228 | catch(e){
229 | // if(e.code == 18){
230 | // TODO: handle IE10 security error
231 | // }
232 | }
233 | }
234 |
235 | window.URL.revokeObjectURL(workerBlob);
236 | }
237 |
238 | if(this.state.searchTerm.length)
239 | this.search({ target: { value: this.state.searchTerm }}) //hacky I know
240 | },
241 |
242 | _closeWorkers: function(){
243 | if(this.props.useWebWorkers && this._threads){
244 | this._threads.forEach(function(thread){
245 | if(thread.worker && thread.worker.terminate)
246 | thread.worker.terminate();
247 | })
248 | }
249 | },
250 |
251 | _updateScrollTop () {
252 | if(this.refs.cont)
253 | this.refs.cont.getDOMNode().scrollTop = Math.max(0, this.state.highlightedIdx - 5) * 28
254 | },
255 |
256 | render: function () {
257 | var items = this.getItems(),
258 | inactive = this.state.selectedItem && !this.state.active,
259 | inpClasses = cx({
260 | "fuzzy-inp": true,
261 | "fuzzy-inp-inactive": inactive
262 | })
263 |
264 | return (
265 |
266 |
280 |
281 | { this.state.active &&
282 |
283 | { items.map(function(result, idx) {
284 | return (
285 | React.createElement(this.props.resultsComponent,
286 | extend(
287 | {
288 | key: result._originalItem[this.props.idField],
289 | highlighted: idx == this.state.highlightedIdx,
290 | nameField: this.props.nameField,
291 | item: result._originalItem,
292 | score: result._score,
293 | selected: result._originalItem == this.state.selectedItem,
294 | selectItem: this.selectItem,
295 | showScore: this.props.showScore
296 | },
297 | this.props.resultsComponentProps
298 | )
299 | )
300 | )
301 | }, this)}
302 |
303 | }
304 |
305 | );
306 | },
307 |
308 | getItems: function(){
309 | if(this.state.searchTerm.length){
310 | return this.state.results
311 | }
312 | else{
313 | if(this.state.items){
314 | if(this.props.maxUnfilteredItems)
315 | return this.state.items.slice(0, this.props.maxUnfilteredItems)
316 | else
317 | return this.state.items;
318 | }
319 |
320 | }
321 |
322 | return [];
323 | },
324 |
325 | getSearchTerms: function(){
326 | return this.state.searchTerm
327 | .split(" ")
328 | .filter(function(term) { return term.length > 0 })
329 | .map(function(term) { return term.toLowerCase() });
330 | },
331 |
332 | handleSpecialKeys (e) {
333 | if(e.keyCode == 13){
334 | if(this.state.highlightedIdx >= 0){
335 | var items = this.getItems(),
336 | selectedItem = items[this.state.highlightedIdx];
337 |
338 | if(selectedItem)
339 | this.selectItem(selectedItem._originalItem)
340 |
341 | this.refs.input.getDOMNode().blur()
342 |
343 | e.stopPropagation();
344 | e.preventDefault();
345 | }
346 | }
347 | else if(e.keyCode == 40 || e.keyCode == 38){
348 | var highlightedIdx = (
349 | Math.min(
350 | this.getItems().length - 1,
351 | Math.max(
352 | 0,
353 | this.state.highlightedIdx + (e.keyCode == 40 ? 1 : -1)
354 | )
355 | )
356 | )
357 |
358 | this.setState({ highlightedIdx }, this._updateScrollTop);
359 | e.stopPropagation();
360 | e.preventDefault();
361 | }
362 | },
363 |
364 | runSearch: function(searchTerms){
365 | var queue = new PriorityQueue(function(a,b) { return b.dist - a.dist }),
366 | results = [],
367 | minDist = 0,
368 | cache = {};
369 |
370 | for(var i = 0; i < this.state.items.length; i++){
371 | var item = this.state.items[i],
372 | totalDist = 0,
373 | flagged = {};
374 |
375 | for(var j = 0; j < searchTerms.length; j++){
376 | var searchTerm = searchTerms[j],
377 | maxDist = 0,
378 | flagPos;
379 |
380 | cache[searchTerm] = cache[searchTerm] || {}
381 |
382 | for(var k = 0; k < item._searchValues.length; k++){
383 | var searchValue = item._searchValues[k],
384 | curDist;
385 |
386 | if(searchTerm == searchValue){
387 | if(!flagged[j]){
388 | flagPos = j;
389 | maxDist = 1.1
390 | break;
391 | }
392 | }
393 | else if(cache[searchTerm][searchValue])
394 | curDist = cache[searchTerm][searchValue]
395 | else
396 | curDist = cache[searchTerm][searchValue] = JaroWinkler.get(searchTerm, searchValue)
397 |
398 | if(curDist > maxDist && (!flagged[j] || curDist > flagged[j])){
399 | flagPos = j;
400 | maxDist = curDist;
401 | }
402 | }
403 |
404 | /*
405 | Worth noting that flagging is crude and only works in one direction. A search term can be a top match
406 | for a search value and then supplanted by a later search term but its maxDist will not be recalculated.
407 | */
408 | flagged[flagPos] = maxDist;
409 | totalDist += maxDist;
410 | }
411 |
412 | if(queue.size() < this.props.maxItems){
413 | if(totalDist < minDist)
414 | minDist = totalDist;
415 | queue.enq({ item: item, dist: totalDist })
416 | }
417 | else if(totalDist > minDist){
418 | queue.deq()
419 | minDist = queue.peek().dist;
420 | queue.enq({ item: item, dist: totalDist })
421 | }
422 | }
423 |
424 | var minScore = this.props.minScore * searchTerms.length;
425 |
426 | while(queue.size()){
427 | var _res = queue.deq();
428 | if(_res.dist > minScore){
429 | _res.item._score = _res.dist;
430 | results.unshift(_res.item)
431 | }
432 | }
433 |
434 | this.setState({
435 | results
436 | })
437 | },
438 |
439 | search: function(e){
440 | var threadID = this.state.threadID + 1,
441 | threadResults = {};
442 |
443 | threadResults[threadID] = []
444 |
445 | this.setState({
446 | searching: true,
447 | searchTerm: e.target.value,
448 | threadID,
449 | threadResults
450 | }, this.startSearch)
451 | },
452 |
453 | selectItem: function (selectedItem) {
454 | this.setState({ active: false, selectedItem });
455 |
456 | if(this.props.onChange)
457 | this.props.onChange(selectedItem);
458 | },
459 |
460 | setActive: function (e) {
461 | this.setState({
462 | active: true,
463 | highlightedIdx: 0
464 | });
465 |
466 | if(this.props.onFocus)
467 | this.props.onFocus(e)
468 | else if(e && e.stopPropagation)
469 | e.stopPropagation();
470 | },
471 |
472 | setInactive: function (e) {
473 | this.setState({
474 | active: false,
475 | highlightedIdx: -1
476 | });
477 |
478 | if(this.props.onBlur)
479 | this.props.onBlur(e)
480 | else if(e && e.stopPropagation)
481 | e.stopPropagation();
482 | },
483 |
484 | startSearch: function(){
485 | var searchTerms = this.getSearchTerms();
486 |
487 | if(this.props.useWebWorkers && _canUseWorkers){
488 | /*
489 | Each new state.searchTerm gets a threadID by which the web workers results will be tracked.
490 | */
491 | for(var i = 0; i < this.props.threadCount; i++){
492 | var worker = this._threads[i].worker,
493 | slice = this.state.slices[i]
494 |
495 | worker.postMessage({
496 | cmd: "search",
497 | opts: {
498 | maxItems: this.props.maxItems
499 | },
500 | searchTerms,
501 | threadID: this.state.threadID
502 | })
503 | }
504 |
505 | this.setState({
506 | searchingAsync: true
507 | })
508 | }
509 | else if(!this.props.immutable){
510 | this.setState(computeSearchValues(this.props.items, this.props), function(){
511 | this.runSearch(searchTerms);
512 | })
513 | }
514 | else{
515 | this.runSearch(searchTerms);
516 | }
517 | },
518 |
519 | onWorkerMessage: function(e){
520 | if(e.data && e.data.results){
521 | var threadResults = this.state.threadResults,
522 | threadResultsForID = threadResults[e.data.threadID]
523 |
524 | if(threadResultsForID){
525 | threadResultsForID.push(e.data.results)
526 | this.state.threadResults[e.data.threadID] = threadResultsForID;
527 |
528 | this.setState({ threadResults })
529 | }
530 | }
531 | }
532 | })
533 |
534 |
535 | module.exports = FuzzySearch;
536 |
--------------------------------------------------------------------------------
/src/css/fuzzy-search.css:
--------------------------------------------------------------------------------
1 | .fuzzy-search{
2 | position: relative;
3 | }
4 |
5 | .fuzzy-inp{
6 | height: 30px;
7 | width: 270px;
8 | padding: 0 4px;
9 | border: 1px solid #ccc;
10 | border-radius: 2px;
11 | box-sizing: border-box;
12 | }
13 |
14 | .fuzzy-inp-inactive{
15 | background-color: #fff;
16 | cursor: pointer;
17 | }
18 |
19 | .fuzzy-results-cont{
20 | position: absolute;
21 | top: 25px;
22 | left: 0;
23 | width: 330px;
24 | padding: 0;
25 | margin: 0;
26 | max-height: 300px;
27 | overflow: hidden;
28 | overflow-y: auto;
29 | background-color: #f9f9f9;
30 | z-index: 100;
31 | text-align: left;
32 | border: 1px solid #bbb;
33 | border-radius: 2px;
34 | }
35 |
36 | .fuzzy-search-result{
37 | height: 24px;
38 | line-height: 24px;
39 | padding: 0px 8px 0 2px;
40 | cursor: pointer;
41 | font-size: 15px;
42 | }
43 |
44 | .fuzzy-search-result-highlighted{
45 | background-color: #ddf;
46 | }
47 |
48 | .fuzzy-search-result:hover{
49 | background-color: #eee;
50 | cursor: pointer;
51 | }
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | module.exports = require("./components/FuzzySearch.js")
--------------------------------------------------------------------------------
/src/utils/JaroWinkler.js:
--------------------------------------------------------------------------------
1 | var jw = {
2 | weight: 0.1,
3 |
4 | get: function(str1, str2){
5 | str1 = str1.toLowerCase();
6 | str2 = str2.toLowerCase();
7 |
8 | var jaroDist = this.jaro(str1, str2);
9 |
10 | // count the number of matching characters up to 4
11 | var matches = 0;
12 | for(var i = 0; i < 4; i++) {
13 | if(str1[i]==str2[i])
14 | matches += 1;
15 | else
16 | break;
17 | }
18 |
19 | return jaroDist + (matches * this.weight * (1 - jaroDist));
20 | },
21 |
22 | jaro: function(str1, str2){
23 | if(str1 == str2)
24 | return 1;
25 | else if(!str1.length || !str2.length)
26 | return 0;
27 | else{
28 | var matchWindow = Math.max(0, Math.floor((Math.max(str1.length, str2.length) / 2) - 1)),
29 | str1Flags = {},
30 | str2Flags = {},
31 | matches = 0;
32 |
33 | for(var i = 0; i < str1.length; i++){
34 | var start = i > matchWindow ? i - matchWindow : 0,
35 | end = i + matchWindow < str2.length ? i + matchWindow : str2.length - 1;
36 |
37 | for(var j = start; j < end + 1; j++){
38 | if(!str2Flags[j] && str2[j] == str1[i]){
39 | str1Flags[i] = str2Flags[j] = true;
40 | matches++;
41 | break;
42 | }
43 | }
44 | }
45 |
46 | if(!matches){
47 | return 0;
48 | }
49 | else{
50 | var transpositions = 0,
51 | str2Offset = 0;
52 |
53 | for(var i = 0; i < str1.length; i++){
54 | if(str1Flags[i]){
55 | for(var j = str2Offset; j < str2.length; j++){
56 | if(str2Flags[j]){
57 | str2Offset = j + 1;
58 | break;
59 | }
60 | }
61 | if(str1[i] != str2[j])
62 | transpositions += 1;
63 | }
64 | }
65 |
66 | transpositions /= 2;
67 |
68 | return ((matches / str1.length) + (matches / str2.length) + ((matches - transpositions) / matches)) / 3;
69 | }
70 | }
71 | },
72 |
73 | getStringMatches (str1, str2 ){
74 | if(str1 == str2)
75 | return str1.split().map(ch => ({ character: ch, isMatch: true, isTransposition: false }))
76 | else if(!str1.length || !str2.length)
77 | return str1.split().map(ch => ({ character: ch, isMatch: false, isTransposition: false }))
78 | else{
79 | var matchWindow = Math.max(0, Math.floor((Math.max(str1.length, str2.length) / 2) - 1)),
80 | str1Flags = {},
81 | str2Flags = {},
82 | matches = 0;
83 |
84 | for(var i = 0; i < str1.length; i++){
85 | var start = i > matchWindow ? i - matchWindow : 0,
86 | end = i + matchWindow < str2.length ? i + matchWindow : str2.length - 1;
87 |
88 | for(var j = start; j < end + 1; j++){
89 | if(!str2Flags[j] && str2[j] == str1[i]){
90 | str1Flags[i] = str2Flags[j] = true;
91 | matches++;
92 | break;
93 | }
94 | }
95 | }
96 |
97 | if(!matches){
98 | return str1.split().map(ch => ({ character: ch, isMatch: false, isTransposition: false }))
99 | }
100 | else{
101 | var transpositions = 0,
102 | str2Offset = 0,
103 | characters = [];
104 |
105 | for(var i = 0; i < str1.length; i++){
106 | if(str1Flags[i]){
107 | for(var j = str2Offset; j < str2.length; j++){
108 | if(str2Flags[j]){
109 | str2Offset = j + 1;
110 | break;
111 | }
112 | }
113 | if(str1[i] != str2[j])
114 | characters.push({ character: str1[i], isMatch: false, isTransposition: true })
115 | else
116 | characters.push({ character: str1[i], isMatch: true, isTransposition: false })
117 | }
118 | else{
119 | characters.push({ character: str1[i], isMatch: false, isTransposition: false })
120 | }
121 | }
122 |
123 | return characters;
124 | }
125 | }
126 | }
127 | }
--------------------------------------------------------------------------------
/src/utils/containsNode.js:
--------------------------------------------------------------------------------
1 | function containsNode(parentNode, childNode) {
2 | if('contains' in parentNode) {
3 | return parentNode.contains(childNode);
4 | }
5 | else {
6 | return parentNode.compareDocumentPosition(childNode) % 16;
7 | }
8 | }
9 |
10 | module.exports = containsNode;
--------------------------------------------------------------------------------
/src/worker/worker.js:
--------------------------------------------------------------------------------
1 | var worker = function(){
2 | /**
3 | priorityqueuejs
4 | https://github.com/janogonzalez/priorityqueuejs
5 | MIT
6 | */
7 |
8 |
9 | /**
10 | * Initializes a new empty `PriorityQueue` with the given `comparator(a, b)`
11 | * function, uses `.DEFAULT_COMPARATOR()` when no function is provided.
12 | *
13 | * The comparator function must return a positive number when `a > b`, 0 when
14 | * `a == b` and a negative number when `a < b`.
15 | *
16 | * @param {Function}
17 | * @return {PriorityQueue}
18 | * @api public
19 | */
20 | function PriorityQueue(comparator) {
21 | this._comparator = comparator || PriorityQueue.DEFAULT_COMPARATOR;
22 | this._elements = [];
23 | }
24 |
25 | /**
26 | * Compares `a` and `b`, when `a > b` it returns a positive number, when
27 | * it returns 0 and when `a < b` it returns a negative number.
28 | *
29 | * @param {String|Number} a
30 | * @param {String|Number} b
31 | * @return {Number}
32 | * @api public
33 | */
34 | PriorityQueue.DEFAULT_COMPARATOR = function(a, b) {
35 | if (typeof a === 'number' && typeof b === 'number') {
36 | return a - b;
37 | } else {
38 | a = a.toString();
39 | b = b.toString();
40 |
41 | if (a == b) return 0;
42 |
43 | return (a > b) ? 1 : -1;
44 | }
45 | };
46 |
47 | /**
48 | * Returns whether the priority queue is empty or not.
49 | *
50 | * @return {Boolean}
51 | * @api public
52 | */
53 | PriorityQueue.prototype.isEmpty = function() {
54 | return this.size() === 0;
55 | };
56 |
57 | /**
58 | * Peeks at the top element of the priority queue.
59 | *
60 | * @return {Object}
61 | * @throws {Error} when the queue is empty.
62 | * @api public
63 | */
64 | PriorityQueue.prototype.peek = function() {
65 | if (this.isEmpty()) throw new Error('PriorityQueue is empty');
66 |
67 | return this._elements[0];
68 | };
69 |
70 | /**
71 | * Dequeues the top element of the priority queue.
72 | *
73 | * @return {Object}
74 | * @throws {Error} when the queue is empty.
75 | * @api public
76 | */
77 | PriorityQueue.prototype.deq = function() {
78 | var first = this.peek();
79 | var last = this._elements.pop();
80 | var size = this.size();
81 |
82 | if (size === 0) return first;
83 |
84 | this._elements[0] = last;
85 | var current = 0;
86 |
87 | while (current < size) {
88 | var largest = current;
89 | var left = (2 * current) + 1;
90 | var right = (2 * current) + 2;
91 |
92 | if (left < size && this._compare(left, largest) >= 0) {
93 | largest = left;
94 | }
95 |
96 | if (right < size && this._compare(right, largest) >= 0) {
97 | largest = right;
98 | }
99 |
100 | if (largest === current) break;
101 |
102 | this._swap(largest, current);
103 | current = largest;
104 | }
105 |
106 | return first;
107 | };
108 |
109 | /**
110 | * Enqueues the `element` at the priority queue and returns its new size.
111 | *
112 | * @param {Object} element
113 | * @return {Number}
114 | * @api public
115 | */
116 | PriorityQueue.prototype.enq = function(element) {
117 | var size = this._elements.push(element);
118 | var current = size - 1;
119 |
120 | while (current > 0) {
121 | var parent = Math.floor((current - 1) / 2);
122 |
123 | if (this._compare(current, parent) <= 0) break;
124 |
125 | this._swap(parent, current);
126 | current = parent;
127 | }
128 |
129 | return size;
130 | };
131 |
132 | /**
133 | * Returns the size of the priority queue.
134 | *
135 | * @return {Number}
136 | * @api public
137 | */
138 | PriorityQueue.prototype.size = function() {
139 | return this._elements.length;
140 | };
141 |
142 | /**
143 | * Iterates over queue elements
144 | *
145 | * @param {Function} fn
146 | */
147 | PriorityQueue.prototype.forEach = function(fn) {
148 | return this._elements.forEach(fn);
149 | };
150 |
151 | /**
152 | * Compares the values at position `a` and `b` in the priority queue using its
153 | * comparator function.
154 | *
155 | * @param {Number} a
156 | * @param {Number} b
157 | * @return {Number}
158 | * @api private
159 | */
160 | PriorityQueue.prototype._compare = function(a, b) {
161 | return this._comparator(this._elements[a], this._elements[b]);
162 | };
163 |
164 | /**
165 | * Swaps the values at position `a` and `b` in the priority queue.
166 | *
167 | * @param {Number} a
168 | * @param {Number} b
169 | * @api private
170 | */
171 | PriorityQueue.prototype._swap = function(a, b) {
172 | var aux = this._elements[a];
173 | this._elements[a] = this._elements[b];
174 | this._elements[b] = aux;
175 | };
176 |
177 | var JaroWinkler = {
178 | weight: 0.1,
179 |
180 | get: function(str1, str2){
181 | str1 = str1.toLowerCase();
182 | str2 = str2.toLowerCase();
183 |
184 | var jaroDist = this.jaro(str1, str2);
185 |
186 | // count the number of matching characters up to 4
187 | var matches = 0;
188 | for(var i = 0; i < 4; i++) {
189 | if(str1[i]==str2[i])
190 | matches += 1;
191 | else
192 | break;
193 | }
194 |
195 | return jaroDist + (matches * this.weight * (1 - jaroDist));
196 | },
197 |
198 | jaro: function(str1, str2){
199 | if(str1 == str2)
200 | return 1;
201 | else if(!str1.length || !str2.length)
202 | return 0;
203 | else{
204 | var matchWindow = Math.max(0, Math.floor((Math.max(str1.length, str2.length) / 2) - 1)),
205 | str1Flags = {},
206 | str2Flags = {},
207 | matches = 0;
208 |
209 | for(var i = 0; i < str1.length; i++){
210 | var start = i > matchWindow ? i - matchWindow : 0,
211 | end = i + matchWindow < str2.length ? i + matchWindow : str2.length - 1;
212 |
213 | for(var j = start; j < end + 1; j++){
214 | if(!str2Flags[j] && str2[j] == str1[i]){
215 | str1Flags[i] = str2Flags[j] = true;
216 | matches++;
217 | break;
218 | }
219 | }
220 | }
221 |
222 | if(!matches){
223 | return 0;
224 | }
225 | else{
226 | var transpositions = 0,
227 | str2Offset = 0;
228 |
229 | for(var i = 0; i < str1.length; i++){
230 | if(str1Flags[i]){
231 | for(var j = str2Offset; j < str2.length; j++){
232 | if(str2Flags[j]){
233 | str2Offset = j + 1;
234 | break;
235 | }
236 | }
237 | if(str1[i] != str2[j])
238 | transpositions += 1;
239 | }
240 | }
241 |
242 | transpositions /= 2;
243 |
244 | return ((matches / str1.length) + (matches / str2.length) + ((matches - transpositions) / matches)) / 3;
245 | }
246 | }
247 | }
248 | }
249 |
250 | var _items;
251 |
252 | self.addEventListener('message', function(e) {
253 | var data = e.data;
254 | switch (data.cmd) {
255 | case 'setData':
256 | _items = data.items;
257 | break;
258 | case 'search':
259 | var results = runSearch(data.searchTerms, _items, data.opts)
260 | self.postMessage({ threadID: data.threadID, results: results })
261 | break;
262 | case 'stop':
263 | self.postMessage('stopped');
264 | self.close();
265 | break;
266 | default:
267 | break;
268 | };
269 | }, false);
270 |
271 |
272 | function runSearch(searchTerms, items, opts){
273 | var queue = new PriorityQueue(function(a,b) { return b.dist - a.dist }),
274 | results = [],
275 | minDist = 0,
276 | cache = {};
277 |
278 | for(var i = 0; i < items.length; i++){
279 | var item = items[i],
280 | totalDist = 0,
281 | flagged = {};
282 |
283 | for(var j = 0; j < searchTerms.length; j++){
284 | var searchTerm = searchTerms[j],
285 | maxDist = 0,
286 | flagPos;
287 |
288 | cache[searchTerm] = cache[searchTerm] || {}
289 |
290 | for(var k = 0; k < item._searchValues.length; k++){
291 | var searchValue = item._searchValues[k],
292 | curDist;
293 |
294 | if(searchTerm == searchValue){
295 | if(!flagged[j]){
296 | flagPos = j;
297 | maxDist = 1.1
298 | break;
299 | }
300 | }
301 | else if(cache[searchTerm][searchValue])
302 | curDist = cache[searchTerm][searchValue]
303 | else
304 | curDist = cache[searchTerm][searchValue] = JaroWinkler.get(searchTerm, searchValue)
305 |
306 | if(curDist > maxDist && (!flagged[j] || curDist > flagged[j])){
307 | flagPos = j;
308 | maxDist = curDist;
309 | }
310 | }
311 |
312 | flagged[flagPos] = maxDist;
313 | totalDist += maxDist;
314 | }
315 |
316 | if(queue.size() < opts.maxItems){
317 | if(totalDist < minDist)
318 | minDist = totalDist;
319 | queue.enq({ item: item, dist: totalDist })
320 | }
321 | else if(totalDist > minDist){
322 | queue.deq()
323 | minDist = queue.peek().dist;
324 | queue.enq({ item: item, dist: totalDist })
325 | }
326 | }
327 |
328 | while(queue.size()){
329 | var _res = queue.deq();
330 | _res.item._score = _res.dist;
331 | results.unshift(_res.item)
332 | }
333 |
334 | return results;
335 | }
336 | }
337 |
338 | module.exports = worker;
--------------------------------------------------------------------------------
/tests.webpack.js:
--------------------------------------------------------------------------------
1 | var context = require.context('./tests', true, /-test\.js$/);
2 | context.keys().forEach(context);
--------------------------------------------------------------------------------
/tests/data.js:
--------------------------------------------------------------------------------
1 | module.exports = [{"id":0,"n":"Tiffany Morton"},{"id":50,"n":"Camacho Weber"},{"id":100,"n":"Lara Oconnor"},{"id":150,"n":"Steele Harris"},{"id":200,"n":"Kellie Baldwin"},{"id":250,"n":"Pollard Sharpe"},{"id":300,"n":"Carissa Valencia"},{"id":350,"n":"Ortiz Jacobson"},{"id":400,"n":"Doreen Levine"},{"id":450,"n":"Campbell David"},{"id":500,"n":"Blankenship Huffman"},{"id":550,"n":"Holly Hensley"},{"id":600,"n":"Boyle Love"},{"id":650,"n":"Cynthia Rodgers"},{"id":700,"n":"Reilly Duran"},{"id":750,"n":"Jefferson Mccullough"},{"id":800,"n":"Sonya Buchanan"},{"id":850,"n":"Joseph Chang"},{"id":900,"n":"Mitzi Mccall"},{"id":950,"n":"Pate Hunt"},{"id":1000,"n":"Leann Snyder"},{"id":1050,"n":"Alyson Crane"},{"id":1100,"n":"May Snider"},{"id":1150,"n":"Bradshaw Schroeder"},{"id":1200,"n":"Jessica Sims"},{"id":1250,"n":"Higgins Perkins"},{"id":1300,"n":"Lola Ingram"},{"id":1350,"n":"Perry Mcdowell"},{"id":1400,"n":"Elisa Sears"},{"id":1450,"n":"Edith Weaver"},{"id":1500,"n":"Fran Britt"},{"id":1550,"n":"Martin Rush"},{"id":1600,"n":"Dixon Jenkins"},{"id":1650,"n":"Roberta Cantrell"},{"id":1700,"n":"Boone Sanders"},{"id":1750,"n":"Solis Espinoza"},{"id":1800,"n":"Oneill Strickland"},{"id":1850,"n":"Verna Phillips"},{"id":1900,"n":"Bridgette Campbell"},{"id":1950,"n":"Norton Roberts"},{"id":2000,"n":"Ebony Guzman"},{"id":2050,"n":"Drake Deleon"},{"id":2100,"n":"Ashlee Moore"},{"id":2150,"n":"Cheryl Logan"},{"id":2200,"n":"Knowles Potter"},{"id":2250,"n":"Lila Howe"},{"id":2300,"n":"Randi Obrien"},{"id":2350,"n":"Humphrey Sloan"},{"id":2400,"n":"Erickson Gould"},{"id":2450,"n":"Tammie Maddox"},{"id":2500,"n":"Daniels Riggs"},{"id":2550,"n":"Schroeder Wooten"},{"id":2600,"n":"Beach Mullins"},{"id":2650,"n":"Heather Joyner"},{"id":2700,"n":"Lynne Mcknight"},{"id":2750,"n":"Coleen Robles"},{"id":2800,"n":"Haley Marquez"},{"id":2850,"n":"Lauren Pratt"},{"id":2900,"n":"Lizzie Pope"},{"id":2950,"n":"Jenna Bentley"},{"id":3000,"n":"Felecia Robertson"},{"id":3050,"n":"Mccarty Clarke"},{"id":3100,"n":"Fern Moses"},{"id":3150,"n":"Patti Russell"},{"id":3200,"n":"Stacey Miller"},{"id":3250,"n":"Diane England"},{"id":3300,"n":"Carey Monroe"},{"id":3350,"n":"Ines Hines"},{"id":3400,"n":"Vaughan Dyer"},{"id":3450,"n":"Kathie Wright"},{"id":3500,"n":"Luann Sandoval"},{"id":3550,"n":"Nona Evans"},{"id":3600,"n":"Harmon Burton"},{"id":3650,"n":"Mosley Chaney"},{"id":3700,"n":"Richardson Cooper"},{"id":3750,"n":"Contreras Stout"},{"id":3800,"n":"Meyers Yates"},{"id":3850,"n":"Elaine Wiley"},{"id":3900,"n":"Medina Mcgee"},{"id":3950,"n":"Weber Griffin"},{"id":4000,"n":"Catherine Peters"},{"id":4050,"n":"Mcgee Hamilton"},{"id":4100,"n":"Flowers Case"},{"id":4150,"n":"Shari Luna"},{"id":4200,"n":"Sullivan Howe"},{"id":4250,"n":"Moody Obrien"},{"id":4300,"n":"Booth Cohen"},{"id":4350,"n":"Watson Spears"},{"id":4400,"n":"Jolene Bradley"},{"id":4450,"n":"Lynda Pennington"},{"id":4500,"n":"Berta Donovan"},{"id":4550,"n":"Hobbs Pittman"},{"id":4600,"n":"Norris Hyde"},{"id":4650,"n":"Hogan Leonard"},{"id":4700,"n":"Anastasia Hendricks"},{"id":4750,"n":"Head Ball"},{"id":4800,"n":"Boyer Reynolds"},{"id":4850,"n":"Kristine Macdonald"},{"id":4900,"n":"Wong Schwartz"},{"id":4950,"n":"Heath Downs"},{"id":5000,"n":"Reed Keith"},{"id":5050,"n":"Leslie Church"},{"id":5100,"n":"Delaney Mckinney"},{"id":5150,"n":"Mcclure West"},{"id":5200,"n":"Lena Ford"},{"id":5250,"n":"Chen Mitchell"},{"id":5300,"n":"Turner Estes"},{"id":5350,"n":"Yolanda Dyer"},{"id":5400,"n":"Robert Snow"},{"id":5450,"n":"Cassie Carter"},{"id":5500,"n":"Pierce Mooney"},{"id":5550,"n":"Lily Cox"},{"id":5600,"n":"Carey Waller"},{"id":5650,"n":"Cannon Walter"},{"id":5700,"n":"Latasha Holden"},{"id":5750,"n":"Stokes Hurley"},{"id":5800,"n":"Chase Hall"},{"id":5850,"n":"Good Ruiz"},{"id":5900,"n":"Rich Velez"},{"id":5950,"n":"Weber Bryan"},{"id":6000,"n":"Finch Duffy"},{"id":6050,"n":"Lavonne Osborn"},{"id":6100,"n":"Petty Hoover"},{"id":6150,"n":"Maldonado Suarez"},{"id":6200,"n":"Doyle Roy"},{"id":6250,"n":"Kristen Ellis"},{"id":6300,"n":"Enid Haley"},{"id":6350,"n":"Cleveland Spencer"},{"id":6400,"n":"Warren Prince"},{"id":6450,"n":"Mamie Wolf"},{"id":6500,"n":"Elise Bass"},{"id":6550,"n":"Schultz Rhodes"},{"id":6600,"n":"Ollie Whitaker"},{"id":6650,"n":"Sara Hahn"},{"id":6700,"n":"Margo Valenzuela"},{"id":6750,"n":"Foreman Dixon"},{"id":6800,"n":"Mccullough William"},{"id":6850,"n":"Richmond Gardner"},{"id":6900,"n":"Lyons Perry"},{"id":6950,"n":"Lorraine White"},{"id":7000,"n":"Odom Hartman"},{"id":7050,"n":"Nash Rojas"},{"id":7100,"n":"Matilda Rogers"},{"id":7150,"n":"Erica Knapp"},{"id":7200,"n":"Dotson Lester"},{"id":7250,"n":"Pope Dalton"},{"id":7300,"n":"Griffin Frederick"},{"id":7350,"n":"Walter Mccormick"},{"id":7400,"n":"Rush Warren"},{"id":7450,"n":"Buckner Whitley"},{"id":7500,"n":"Ola Lambert"},{"id":7550,"n":"Beth Adams"},{"id":7600,"n":"Blackburn Cooley"},{"id":7650,"n":"Rivers Wilkins"},{"id":7700,"n":"Traci Atkins"},{"id":7750,"n":"Miranda Clay"},{"id":7800,"n":"Genevieve Carson"},{"id":7850,"n":"Ebony Park"},{"id":7900,"n":"Lacey Taylor"},{"id":7950,"n":"Angel Delaney"},{"id":8000,"n":"Guerra Fields"},{"id":8050,"n":"Kellie Gilmore"},{"id":8100,"n":"Kristie Stone"},{"id":8150,"n":"Hendrix Larsen"},{"id":8200,"n":"Gilliam Palmer"},{"id":8250,"n":"Phyllis Landry"},{"id":8300,"n":"Hatfield Figueroa"},{"id":8350,"n":"Price Welch"},{"id":8400,"n":"Bernadine Morrison"},{"id":8450,"n":"Ursula Carver"},{"id":8500,"n":"Adeline Armstrong"},{"id":8550,"n":"Michelle Neal"},{"id":8600,"n":"Consuelo Blackburn"},{"id":8650,"n":"Jan Madden"},{"id":8700,"n":"Guy Randall"},{"id":8750,"n":"Cherry Aguilar"},{"id":8800,"n":"Meredith Wheeler"},{"id":8850,"n":"White Davenport"},{"id":8900,"n":"Cardenas Gilliam"},{"id":8950,"n":"Wall Kelley"},{"id":9000,"n":"Henry Mcdaniel"},{"id":9050,"n":"Serrano Case"},{"id":9100,"n":"Clara Spence"},{"id":9150,"n":"Castro Workman"},{"id":9200,"n":"Penny Ortega"},{"id":9250,"n":"Juana Curtis"},{"id":9300,"n":"Margie Tanner"},{"id":9350,"n":"Irene Jacobs"},{"id":9400,"n":"Cassie Gates"},{"id":9450,"n":"Owens Day"},{"id":9500,"n":"Roberts Love"},{"id":9550,"n":"Estelle Rodgers"},{"id":9600,"n":"Spence Duran"},{"id":9650,"n":"Valentine Simpson"},{"id":9700,"n":"Traci Mckay"},{"id":9750,"n":"Elena Davidson"},{"id":9800,"n":"Mays Booker"},{"id":9850,"n":"Nelson Randolph"},{"id":9900,"n":"Roman Mooney"},{"id":9950,"n":"Roxanne Mullen"},{"id":10000,"n":"Graham Weber"},{"id":10050,"n":"Elva Adkins"},{"id":10100,"n":"Johnston Wilkinson"},{"id":10150,"n":"Marianne Petty"},{"id":10200,"n":"Lindsay Craft"},{"id":10250,"n":"Georgia Hensley"},{"id":10300,"n":"Sara Whitney"},{"id":10350,"n":"Dotson Sims"},{"id":10400,"n":"Arlene Stephens"},{"id":10450,"n":"Daisy Hammond"},{"id":10500,"n":"Ofelia Holcomb"},{"id":10550,"n":"Marylou Richards"},{"id":10600,"n":"Foley Collins"},{"id":10650,"n":"Joyce Velez"},{"id":10700,"n":"Tran Haney"},{"id":10750,"n":"Jeannette Donovan"},{"id":10800,"n":"Hattie Bird"},{"id":10850,"n":"Vargas Rosales"},{"id":10900,"n":"Guadalupe Day"},{"id":10950,"n":"Cara Huffman"},{"id":11000,"n":"Alejandra Small"},{"id":11050,"n":"Salazar Parker"},{"id":11100,"n":"Lora Conner"},{"id":11150,"n":"Mona Sykes"},{"id":11200,"n":"Ramona Baxter"},{"id":11250,"n":"Mcknight Keller"},{"id":11300,"n":"Stephenson Morris"},{"id":11350,"n":"Booker Phelps"},{"id":11400,"n":"Augusta Stuart"},{"id":11450,"n":"Maria Bradshaw"},{"id":11500,"n":"Price Weiss"},{"id":11550,"n":"Laurel Rosa"},{"id":11600,"n":"Durham Kelley"},{"id":11650,"n":"Bernadette Bush"},{"id":11700,"n":"Corina Becker"},{"id":11750,"n":"Carpenter Finch"},{"id":11800,"n":"Eileen Parsons"},{"id":11850,"n":"Shauna Frazier"},{"id":11900,"n":"Sarah Levy"},{"id":11950,"n":"Henson Bernard"},{"id":12000,"n":"Arline Price"},{"id":12050,"n":"Carlene Ward"},{"id":12100,"n":"Crane Hester"},{"id":12150,"n":"Lourdes Bradley"},{"id":12200,"n":"Yates Valentine"},{"id":12250,"n":"Adela Roth"},{"id":12300,"n":"Juarez Ramos"},{"id":12350,"n":"Cox Nieves"},{"id":12400,"n":"Odonnell Stanton"},{"id":12450,"n":"Nunez Case"},{"id":12500,"n":"Sheila Le"},{"id":12550,"n":"Callie Ortiz"},{"id":12600,"n":"Lessie Thompson"},{"id":12650,"n":"Shanna Burgess"},{"id":12700,"n":"David Stafford"},{"id":12750,"n":"Billie Meadows"},{"id":12800,"n":"Rodgers Reese"},{"id":12850,"n":"Turner Barlow"},{"id":12900,"n":"Lewis Elliott"},{"id":12950,"n":"Castaneda Vazquez"},{"id":13000,"n":"Araceli Holder"},{"id":13050,"n":"Nikki York"},{"id":13100,"n":"Beulah Riggs"},{"id":13150,"n":"Emerson Baker"},{"id":13200,"n":"Sherman Coleman"},{"id":13250,"n":"Keri Marks"},{"id":13300,"n":"Kendra Joseph"},{"id":13350,"n":"Jody Barnes"},{"id":13400,"n":"Victoria Morgan"},{"id":13450,"n":"Mosley Castro"},{"id":13500,"n":"Joan Chambers"},{"id":13550,"n":"Joanne Henry"},{"id":13600,"n":"Julie Yates"},{"id":13650,"n":"Wolfe Clay"},{"id":13700,"n":"French Chase"},{"id":13750,"n":"Sally Cox"},{"id":13800,"n":"Abby Jones"},{"id":13850,"n":"Warner Jarvis"},{"id":13900,"n":"Woodard Campos"},{"id":13950,"n":"Donovan Weber"},{"id":14000,"n":"Madden Lucas"},{"id":14050,"n":"Crane Cabrera"},{"id":14100,"n":"Marcie Bowers"},{"id":14150,"n":"Merrill Craft"},{"id":14200,"n":"Orr Hensley"},{"id":14250,"n":"Grimes Brock"},{"id":14300,"n":"Ilene Glover"},{"id":14350,"n":"Key Stephens"},{"id":14400,"n":"Rhodes Hammond"},{"id":14450,"n":"Dominique Holcomb"},{"id":14500,"n":"Ella Colon"},{"id":14550,"n":"Terry Dillard"},{"id":14600,"n":"Golden Puckett"},{"id":14650,"n":"Kasey Chandler"},{"id":14700,"n":"Marie Cochran"},{"id":14750,"n":"Daphne Stein"},{"id":14800,"n":"Margaret Kirby"},{"id":14850,"n":"Prince Norris"},{"id":14900,"n":"Pena Mills"},{"id":14950,"n":"Shannon Middleton"},{"id":15000,"n":"Lester Lane"},{"id":15050,"n":"Christian Santana"},{"id":15100,"n":"Pierce Zamora"},{"id":15150,"n":"Leann Richardson"},{"id":15200,"n":"Benson Logan"},{"id":15250,"n":"Ebony Hooper"},{"id":15300,"n":"Ava Joyce"},{"id":15350,"n":"Barnes Stuart"},{"id":15400,"n":"Michelle Briggs"},{"id":15450,"n":"Summers Rutledge"},{"id":15500,"n":"Jenny Rosa"},{"id":15550,"n":"Alta Garza"},{"id":15600,"n":"Patti Torres"},{"id":15650,"n":"Natasha Downs"},{"id":15700,"n":"Wolfe Donaldson"},{"id":15750,"n":"Grant Dale"},{"id":15800,"n":"Lang House"},{"id":15850,"n":"Rice Cole"},{"id":15900,"n":"Herrera Farrell"},{"id":15950,"n":"Leila Cook"},{"id":16000,"n":"Kelley Dominguez"},{"id":16050,"n":"Tasha Lynn"},{"id":16100,"n":"Latisha Kirk"},{"id":16150,"n":"Lara Bruce"},{"id":16200,"n":"Vicky Holden"},{"id":16250,"n":"Tia Watts"},{"id":16300,"n":"Gallagher Crane"},{"id":16350,"n":"Cathryn Duffy"},{"id":16400,"n":"Maggie Macdonald"},{"id":16450,"n":"Nunez Campbell"},{"id":16500,"n":"Evangelina Armstrong"},{"id":16550,"n":"Kasey Jacobs"},{"id":16600,"n":"Hester Benjamin"},{"id":16650,"n":"Dolly Andrews"},{"id":16700,"n":"Baker Keith"},{"id":16750,"n":"Fowler Ellison"},{"id":16800,"n":"Pace Juarez"},{"id":16850,"n":"Estelle Tyler"},{"id":16900,"n":"Gina Horn"},{"id":16950,"n":"Jean Cote"},{"id":17000,"n":"Horton Holman"},{"id":17050,"n":"Carolina Bradley"},{"id":17100,"n":"Ashlee Valentine"},{"id":17150,"n":"Bowen Roth"},{"id":17200,"n":"Gabriela Moore"},{"id":17250,"n":"Shawn Whitehead"},{"id":17300,"n":"Mabel Gregory"},{"id":17350,"n":"Ewing Byers"},{"id":17400,"n":"Roseann Peterson"},{"id":17450,"n":"English Mccarthy"},{"id":17500,"n":"Dunn Mosley"},{"id":17550,"n":"Eva Luna"},{"id":17600,"n":"Baxter Farley"},{"id":17650,"n":"Lawson Lester"},{"id":17700,"n":"Silva Delgado"},{"id":17750,"n":"Watkins Bowman"},{"id":17800,"n":"Martin Herman"},{"id":17850,"n":"Fay Mcfadden"},{"id":17900,"n":"Sue Humphrey"},{"id":17950,"n":"Hamilton Hays"},{"id":18000,"n":"Elisabeth Holt"},{"id":18050,"n":"Janie Mckay"},{"id":18100,"n":"Lauri Pugh"},{"id":18150,"n":"Cobb Foley"},{"id":18200,"n":"Sexton Riley"},{"id":18250,"n":"Callahan Chaney"},{"id":18300,"n":"Sandy Burton"},{"id":18350,"n":"Simmons Emerson"},{"id":18400,"n":"Imelda Booth"},{"id":18450,"n":"Latasha Mayer"},{"id":18500,"n":"Nichols Duncan"},{"id":18550,"n":"Chambers Kramer"},{"id":18600,"n":"Marietta Russell"},{"id":18650,"n":"Byers Garrison"},{"id":18700,"n":"Violet Sharpe"},{"id":18750,"n":"Harmon Robbins"},{"id":18800,"n":"Vargas Macias"},{"id":18850,"n":"Lydia Banks"},{"id":18900,"n":"Whitehead Dickson"},{"id":18950,"n":"Stephens Mitchell"},{"id":19000,"n":"Ramona Noel"},{"id":19050,"n":"Sadie Huff"},{"id":19100,"n":"Kendra Francis"},{"id":19150,"n":"Dianne Deleon"},{"id":19200,"n":"Rosella Pennington"},{"id":19250,"n":"Rose Gallagher"},{"id":19300,"n":"Nieves Skinner"},{"id":19350,"n":"Guy Mejia"},{"id":19400,"n":"Eliza Reynolds"},{"id":19450,"n":"Ellison Rose"},{"id":19500,"n":"Valerie Ballard"}]
--------------------------------------------------------------------------------
/tests/search-test.js:
--------------------------------------------------------------------------------
1 | var React = require('react');
2 | var TestUtils = require('react/lib/ReactTestUtils');
3 | var expect = require('expect');
4 | var FuzzySearch = require("../src/index")
5 | var _testData = require("./data")
6 |
7 |
8 | function createComponent(onChange, resultsComponent, resultsComponentProps){
9 | return (
10 |
22 | );
23 | }
24 |
25 | describe("Search arbritary data and find best matches", function(){
26 | it('renders', function () {
27 | var fuzzySearch = TestUtils.renderIntoDocument(createComponent());
28 | expect(fuzzySearch).toExist();
29 | });
30 |
31 | it("returns results", function(done){
32 | var fuzzySearch = TestUtils.renderIntoDocument(createComponent());
33 | var inp = TestUtils.findRenderedDOMComponentWithClass(fuzzySearch, "fuzzy-inp")
34 |
35 | TestUtils.Simulate.focus(inp)
36 | TestUtils.Simulate.change(inp, { target: { value: "tiffany morton"}})
37 |
38 | setTimeout(function(){
39 | var results = TestUtils.scryRenderedDOMComponentsWithClass(fuzzySearch, "fuzzy-search-result")
40 | expect(results.length).toBeGreaterThan(0)
41 | done();
42 | }, 300)
43 | })
44 |
45 | it("ranks an exact match number one", function(done){
46 | var fuzzySearch = TestUtils.renderIntoDocument(createComponent());
47 | var inp = TestUtils.findRenderedDOMComponentWithClass(fuzzySearch, "fuzzy-inp")
48 |
49 | TestUtils.Simulate.focus(inp)
50 | TestUtils.Simulate.change(inp, { target: { value: "tiffany morton"}})
51 |
52 | setTimeout(function(){
53 | var results = TestUtils.scryRenderedDOMComponentsWithClass(fuzzySearch, "fuzzy-search-result")
54 | expect(results[0].getDOMNode().textContent).toBe("Tiffany Morton")
55 | done();
56 | }, 300)
57 | })
58 |
59 | it("ranks closest match number one", function(done){
60 | var fuzzySearch = TestUtils.renderIntoDocument(createComponent());
61 | var inp = TestUtils.findRenderedDOMComponentWithClass(fuzzySearch, "fuzzy-inp")
62 |
63 | TestUtils.Simulate.focus(inp)
64 | TestUtils.Simulate.change(inp, { target: { value: "tiphanie morten"}})
65 |
66 | setTimeout(function(){
67 | var results = TestUtils.scryRenderedDOMComponentsWithClass(fuzzySearch, "fuzzy-search-result")
68 | expect(results[0].getDOMNode().textContent).toBe("Tiffany Morton")
69 | done();
70 | }, 300)
71 | })
72 |
73 | it("excludes results that are poor matches", function(done){
74 | var fuzzySearch = TestUtils.renderIntoDocument(createComponent());
75 | var inp = TestUtils.findRenderedDOMComponentWithClass(fuzzySearch, "fuzzy-inp")
76 |
77 | TestUtils.Simulate.focus(inp)
78 | TestUtils.Simulate.change(inp, { target: { value: "doreen levine"}})
79 |
80 | setTimeout(function(){
81 | var results = TestUtils.scryRenderedDOMComponentsWithClass(fuzzySearch, "fuzzy-search-result")
82 | expect(results.some(n => n.getDOMNode().textContent == "Dong Schwartz")).toBe(false)
83 | done();
84 | }, 300)
85 | })
86 |
87 | it("Uses a custom component passed as resultsComponent", function(done){
88 | var customComponent = React.createClass({
89 | render: function() { return
}
90 | })
91 | var fuzzySearch = TestUtils.renderIntoDocument(createComponent(null, customComponent));
92 | var inp = TestUtils.findRenderedDOMComponentWithClass(fuzzySearch, "fuzzy-inp")
93 |
94 |
95 | TestUtils.Simulate.focus(inp)
96 | TestUtils.Simulate.change(inp, { target: { value: "doreen levine"}})
97 |
98 | setTimeout(function(){
99 | var renderedCustomComponent = TestUtils.scryRenderedDOMComponentsWithClass(fuzzySearch, "custom-component")
100 | expect(renderedCustomComponent.length).toBe(12)
101 | done();
102 | }, 300)
103 |
104 | })
105 |
106 | afterEach(function(done){
107 | var React = require("react/addons");
108 | React.unmountComponentAtNode(document.body);
109 | document.body.innerHTML == "";
110 | setTimeout(done);
111 | })
112 | })
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | var path = require("path")
2 | var webpack = require("webpack")
3 |
4 | var config = {
5 | entry: './src/index.js',
6 | output: {
7 | filename: 'build/bundle.js',
8 | publicPath: "build"
9 | },
10 | module: {
11 | loaders: [
12 | { test: /\.js?$/, exclude: /node_modules/, loader: 'babel-loader' },
13 | ]
14 | },
15 | plugins: [ ]
16 | };
17 |
18 | if(process.env.NODE_ENV === 'production') {
19 | config.plugins = config.plugins.concat([
20 | new webpack.DefinePlugin({
21 | "process.env": {
22 | NODE_ENV: JSON.stringify("production")
23 | }
24 | }),
25 | new webpack.optimize.DedupePlugin(),
26 | new webpack.optimize.UglifyJsPlugin()
27 | ]);
28 | }
29 | else {
30 | config.devtool = 'sourcemap';
31 | config.debug = true;
32 | }
33 |
34 | module.exports = config;
35 |
--------------------------------------------------------------------------------