Add Token Select Modal

This commit is contained in:
Chi Kei Chan 2018-10-07 02:38:43 -07:00
parent 8847f4e4d2
commit 1d86c0d656
23 changed files with 1219 additions and 19 deletions

@ -7,6 +7,7 @@
"axios": "^0.18.0",
"classnames": "^2.2.6",
"d3": "^4.13.0",
"fuse": "^0.4.0",
"jazzicon": "^1.5.0",
"node-sass": "^4.9.3",
"npm": "^6.0.0",
@ -20,6 +21,7 @@
"react-scripts": "2.0.4",
"react-scroll-to-component": "^1.0.2",
"react-select": "^1.2.1",
"react-transition-group": "1.x",
"redux": "^3.7.2",
"redux-subscriber": "^1.1.0",
"redux-thunk": "^2.2.0",

@ -26,6 +26,7 @@
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
<div id="modal-root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.2 KiB

@ -4,8 +4,7 @@
@extend %col-nowrap;
&__input {
color: $mine-shaft-gray;
font-size: .9rem;
font-size: .75rem;
outline: none;
border: none;
flex: 1 1 auto;

@ -41,16 +41,7 @@
}
&__input {
color: $mine-shaft-gray;
font-size: 1.5rem;
outline: none;
border: none;
flex: 1 1 auto;
width: 0;
&::placeholder {
color: $chalice-gray;
}
@extend %borderless-input;
}
&__currency-select {
@ -70,6 +61,17 @@
&:active {
background-color: rgba($zumthor-blue, .8);
}
&--selected {
background-color: $concrete-gray;
border-color: $mercury-gray;
color: $black;
padding: 0 .5rem;
.currency-input-panel__dropdown-icon {
background-image: url(../../assets/images/dropdown.svg);
}
}
}
&__dropdown-icon {
@ -81,5 +83,82 @@
background-size: contain;
background-position: 50% 50%;
}
&__selected-token-logo {
margin-right: .4rem;
object-fit: contain;
}
}
.token-modal {
background-color: $white;
position: relative;
bottom: 21rem;
width: 100%;
height: 21rem;
z-index: 2000;
border-top-left-radius: 1rem;
border-top-right-radius: 1rem;
transition: 250ms ease-in-out;
&__search-container {
@extend %row-nowrap;
padding: 1rem;
border-bottom: 1px solid $mercury-gray;
}
&__search-input {
@extend %borderless-input;
}
&__search-icon {
margin-right: .2rem;
}
&__token-list {
height: 17.5rem;
overflow-y: auto;
}
&__token-row {
@extend %row-nowrap;
align-items: center;
padding: 1rem 1.5rem;
justify-content: space-between;
cursor: pointer;
user-select: none;
&:hover {
background-color: $concrete-gray;
.token-modal__token-label {
color: $black;
}
}
&:active {
background-color: darken($concrete-gray, 1);
}
}
&__token-logo {
object-fit: contain;
}
&__token-label {
color: $silver-gray;
font-weight: 200;
}
}
.token-modal-appear {
bottom: 0;
}
.token-modal-appear.modal-container-appear-active {
bottom: 0;
}

@ -1,9 +1,32 @@
import React, { Component } from 'react';
import { connect } from 'react-redux';
import PropTypes from 'prop-types';
import { CSSTransitionGroup } from "react-transition-group";
import classnames from 'classnames';
import Fuse from '../../helpers/fuse';
import Modal from '../Modal';
import TokenLogo from '../TokenLogo';
import SearchIcon from '../../assets/images/magnifying-glass.svg';
import './currency-panel.scss';
const TOKEN_ICON_API = 'https://raw.githubusercontent.com/TrustWallet/tokens/master/images';
const FUSE_OPTIONS = {
includeMatches: false,
threshold: 0.0,
tokenize:true,
location: 0,
distance: 100,
maxPatternLength: 45,
minMatchCharLength: 1,
keys: [
{name:"address",weight:0.8},
{name:"label",weight:0.5},
]
};
const TOKEN_ADDRESS_TO_LABEL = { ETH: 'ETH' };
class CurrencyInputPanel extends Component {
static propTypes = {
title: PropTypes.string,
@ -11,6 +34,93 @@ class CurrencyInputPanel extends Component {
extraText: PropTypes.string,
};
state = {
isShowingModal: false,
searchQuery: '',
selectedTokenAddress: '',
};
createTokenList = () => {
let tokens = this.props.web3Store.tokenAddresses.addresses;
let tokenList = [ { value: 'ETH', label: 'ETH', address: 'ETH', clearableValue: false } ];
for (let i = 0; i < tokens.length; i++) {
let entry = { value: '', label: '', clearableValue: false }
entry.value = tokens[i][0];
entry.label = tokens[i][0];
entry.address = tokens[i][1];
tokenList.push(entry);
TOKEN_ADDRESS_TO_LABEL[tokens[i][1]] = tokens[i][0];
}
return tokenList;
}
renderTokenList() {
const tokens = this.createTokenList();
const { searchQuery } = this.state;
let results;
if (!searchQuery) {
results = tokens;
} else {
const fuse = new Fuse(tokens, FUSE_OPTIONS);
results = fuse.search(this.state.searchQuery);
}
return results.map(({ label, address }) => (
<div
key={label}
className="token-modal__token-row"
onClick={() => this.setState({
selectedTokenAddress: address || 'ETH',
searchQuery: '',
isShowingModal: false,
})}
>
<TokenLogo className="token-modal__token-logo" address={address} />
<div className="token-modal__token-label" >{label}</div>
</div>
));
}
renderModal() {
if (!this.state.isShowingModal) {
return null;
}
return (
<Modal onClose={() => this.setState({ isShowingModal: false })}>
<CSSTransitionGroup
transitionName="token-modal"
transitionAppear={true}
transitionLeave={true}
transitionAppearTimeout={200}
transitionLeaveTimeout={200}
transitionEnterTimeout={200}
>
<div className="token-modal">
<div className="token-modal__search-container">
<input
type="text"
placeholder="Search Token or Paste Address"
className="token-modal__search-input"
onChange={e => this.setState({
searchQuery: e.target.value,
})}
/>
<img src={SearchIcon} className="token-modal__search-icon" />
</div>
<div className="token-modal__token-list">
{this.renderTokenList()}
</div>
</div>
</CSSTransitionGroup>
</Modal>
);
}
render() {
const {
title,
@ -18,6 +128,8 @@ class CurrencyInputPanel extends Component {
extraText,
} = this.props;
const { selectedTokenAddress } = this.state;
return (
<div className="currency-input-panel">
<div className="currency-input-panel__container">
@ -30,15 +142,33 @@ class CurrencyInputPanel extends Component {
</div>
<div className="currency-input-panel__input-row">
<input type="number" className="currency-input-panel__input" placeholder="0.0" />
<button className="currency-input-panel__currency-select">
Select a token
<button
className={classnames("currency-input-panel__currency-select", {
'currency-input-panel__currency-select--selected': selectedTokenAddress,
})}
onClick={() => this.setState({ isShowingModal: true })}
>
{
selectedTokenAddress
? (
<TokenLogo
className="currency-input-panel__selected-token-logo"
address={selectedTokenAddress}
/>
)
: null
}
{ TOKEN_ADDRESS_TO_LABEL[selectedTokenAddress]|| 'Select a token' }
<span className="currency-input-panel__dropdown-icon" />
</button>
</div>
</div>
{this.renderModal()}
</div>
)
}
}
export default connect()(CurrencyInputPanel);
export default connect(
state => ({ web3Store: state.web3Store })
)(CurrencyInputPanel);

@ -1,4 +1,5 @@
import React, { Component }from 'react';
import React, { Component }from 'react';
import SelectToken from './SelectToken';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';

@ -0,0 +1,57 @@
import React, { Component } from 'react';
import ReactDOM from 'react-dom';
import PropTypes from 'prop-types';
import { CSSTransitionGroup } from 'react-transition-group';
import './modal.scss';
const modalRoot = document.querySelector('#modal-root');
export default class Modal extends Component {
static propTypes = {
onClose: PropTypes.func.isRequired,
};
constructor(props) {
super(props);
// this.el = document.createElement('div');
}
componentDidMount() {
// The portal element is inserted in the DOM tree after
// the Modal's children are mounted, meaning that children
// will be mounted on a detached DOM node. If a child
// component requires to be attached to the DOM tree
// immediately when mounted, for example to measure a
// DOM node, or uses 'autoFocus' in a descendant, add
// state to Modal and only render the children when Modal
// is inserted in the DOM tree.
// modalRoot.style.display = 'block';
// modalRoot.appendChild(this.el);
}
componentWillUnmount() {
setTimeout(() => {
// modalRoot.style.display = 'none';
// modalRoot.removeChild(this.el);
}, 500);
}
render() {
return ReactDOM.createPortal(
<div>
<CSSTransitionGroup
transitionName="modal-container"
transitionAppear={true}
transitionLeave={true}
transitionAppearTimeout={200}
transitionLeaveTimeout={200}
transitionEnterTimeout={200}
>
<div className="modal-container" onClick={this.props.onClose} key="modal" />
</CSSTransitionGroup>
{this.props.children}
</div>,
modalRoot,
);
}
}

@ -0,0 +1,18 @@
@import '../../variables.scss';
.modal-container {
position: relative;
height: 100vh;
width: 100vw;
background-color: rgba($black, .6);
z-index: 1000;
}
.modal-container-appear {
opacity: 0.01;
}
.modal-container-appear.modal-container-appear-active {
opacity: 1;
transition: opacity 200ms ease-in-out;
}

@ -0,0 +1,49 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import EthereumLogo from '../../assets/images/ethereum-logo.png';
const TOKEN_ICON_API = 'https://raw.githubusercontent.com/TrustWallet/tokens/master/images';
export default class TokenLogo extends Component {
static propTypes = {
address: PropTypes.string,
size: PropTypes.string,
className: PropTypes.string,
};
static defaultProps = {
address: '',
size: '1.5rem',
className: '',
};
state = {
error: false,
};
render() {
const { address, size, className } = this.props;
let path = 'https://png2.kisspng.com/sh/59799e422ec61954a8e126f9203cd0b3/L0KzQYm3U8I6N5xniZH0aYP2gLBuTflxcJDzfZ9ubXBteX76gf10fZ9sRdlqbHH7iX7ulfV0e155gNc2cYXog8XwjB50NZR3kdt3Zz3ofbFxib02aZNoetUAYkC6QoXrVr4zP2Y1SKkBNkG4QoO6Ucg1Omg1Sqs8LoDxd1==/kisspng-iphone-emoji-samsung-galaxy-guess-the-questions-crying-emoji-5abcbc5b0724d6.2750076615223184270293.png';
if (address === 'ETH') {
path = EthereumLogo;
}
if (!this.state.error) {
path = `${TOKEN_ICON_API}/${address}.png`;
}
return (
<img
src={path}
className={className}
style={{
width: size,
height: size,
}}
onError={() => this.setState({ error: true })}
/>
);
}
}

@ -0,0 +1,26 @@
export default function (matchmask = [], minMatchCharLength = 1) {
let matchedIndices = []
let start = -1
let end = -1
let i = 0
for (let len = matchmask.length; i < len; i += 1) {
let match = matchmask[i]
if (match && start === -1) {
start = i
} else if (!match && start !== -1) {
end = i - 1
if ((end - start) + 1 >= minMatchCharLength) {
matchedIndices.push([start, end])
}
start = -1
}
}
// (i-1 - start) + 1 => i - start
if (matchmask[i - 1] && (i - start) >= minMatchCharLength) {
matchedIndices.push([start, i - 1])
}
return matchedIndices
}

@ -0,0 +1,14 @@
export default function (pattern) {
let mask = {}
let len = pattern.length
for (let i = 0; i < len; i += 1) {
mask[pattern.charAt(i)] = 0
}
for (let i = 0; i < len; i += 1) {
mask[pattern.charAt(i)] |= 1 << (len - i - 1)
}
return mask
}

@ -0,0 +1,22 @@
const SPECIAL_CHARS_REGEX = /[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g
export default function (text, pattern, tokenSeparator = / +/g) {
let regex = new RegExp(pattern.replace(SPECIAL_CHARS_REGEX, '\\$&').replace(tokenSeparator, '|'))
let matches = text.match(regex)
let isMatch = !!matches
let matchedIndices = []
if (isMatch) {
for (let i = 0, matchesLen = matches.length; i < matchesLen; i += 1) {
let match = matches[i]
matchedIndices.push([text.indexOf(match), match.length - 1])
}
}
return {
// TODO: revisit this score
score: isMatch ? 0.5 : 1,
isMatch,
matchedIndices
}
}

@ -0,0 +1,11 @@
export default function (pattern, { errors = 0, currentLocation = 0, expectedLocation = 0, distance = 100 }) {
const accuracy = errors / pattern.length
const proximity = Math.abs(expectedLocation - currentLocation)
if (!distance) {
// Dodge divide by zero error.
return proximity ? 1.0 : accuracy
}
return accuracy + (proximity / distance)
}

@ -0,0 +1,157 @@
import Bitap from "./index";
import bitapScore from './bitap_score';
import matchedIndices from './bitap_matched_indices';
export default function (text, pattern, patternAlphabet, { location = 0, distance = 100, threshold = 0.6, findAllMatches = false, minMatchCharLength = 1 }) {
const expectedLocation = location
// Set starting location at beginning text and initialize the alphabet.
const textLen = text.length
// Highest score beyond which we give up.
let currentThreshold = threshold
// Is there a nearby exact match? (speedup)
let bestLocation = text.indexOf(pattern, expectedLocation)
const patternLen = pattern.length
// a mask of the matches
const matchMask = []
for (let i = 0; i < textLen; i += 1) {
matchMask[i] = 0
}
if (bestLocation !== -1) {
let score = bitapScore(pattern, {
errors: 0,
currentLocation: bestLocation,
expectedLocation,
distance
})
currentThreshold = Math.min(score, currentThreshold)
// What about in the other direction? (speed up)
bestLocation = text.lastIndexOf(pattern, expectedLocation + patternLen)
if (bestLocation !== -1) {
let score = bitapScore(pattern, {
errors: 0,
currentLocation: bestLocation,
expectedLocation,
distance
})
currentThreshold = Math.min(score, currentThreshold)
}
}
// Reset the best location
bestLocation = -1
let lastBitArr = []
let finalScore = 1
let binMax = patternLen + textLen
const mask = 1 << (patternLen - 1)
for (let i = 0; i < patternLen; i += 1) {
// Scan for the best match; each iteration allows for one more error.
// Run a binary search to determine how far from the match location we can stray
// at this error level.
let binMin = 0
let binMid = binMax
while (binMin < binMid) {
const score = bitapScore(pattern, {
errors: i,
currentLocation: expectedLocation + binMid,
expectedLocation,
distance
});
if (score <= currentThreshold) {
binMin = binMid
} else {
binMax = binMid
}
binMid = Math.floor((binMax - binMin) / 2 + binMin)
}
// Use the result from this iteration as the maximum for the next.
binMax = binMid
let start = Math.max(1, expectedLocation - binMid + 1)
let finish = findAllMatches ? textLen : Math.min(expectedLocation + binMid, textLen) + patternLen
// Initialize the bit array
let bitArr = Array(finish + 2)
bitArr[finish + 1] = (1 << i) - 1
for (let j = finish; j >= start; j -= 1) {
let currentLocation = j - 1
let charMatch = patternAlphabet[text.charAt(currentLocation)]
if (charMatch) {
matchMask[currentLocation] = 1
}
// First pass: exact match
bitArr[j] = ((bitArr[j + 1] << 1) | 1) & charMatch
// Subsequent passes: fuzzy match
if (i !== 0) {
bitArr[j] |= (((lastBitArr[j + 1] | lastBitArr[j]) << 1) | 1) | lastBitArr[j + 1]
}
if (bitArr[j] & mask) {
finalScore = bitapScore(pattern, {
errors: i,
currentLocation,
expectedLocation,
distance
})
// This match will almost certainly be better than any existing match.
// But check anyway.
if (finalScore <= currentThreshold) {
// Indeed it is
currentThreshold = finalScore
bestLocation = currentLocation
// Already passed `loc`, downhill from here on in.
if (bestLocation <= expectedLocation) {
break
}
// When passing `bestLocation`, don't exceed our current distance from `expectedLocation`.
start = Math.max(1, 2 * expectedLocation - bestLocation)
}
}
}
// No hope for a (better) match at greater error levels.
const score = bitapScore(pattern, {
errors: i + 1,
currentLocation: expectedLocation,
expectedLocation,
distance
})
// console.log('score', score, finalScore)
if (score > currentThreshold) {
break
}
lastBitArr = bitArr
}
// console.log('FINAL SCORE', finalScore)
// Count exact matches (those with a score of 0) to be "almost" exact
return {
isMatch: bestLocation >= 0,
score: finalScore === 0 ? 0.001 : finalScore,
matchedIndices: matchedIndices(matchMask, minMatchCharLength)
}
}

@ -0,0 +1,84 @@
import bitapRegexSearch from './bitap_regex_search';
import bitapSearch from './bitap_search';
import patternAlphabet from './bitap_pattern_alphabet';
class Bitap {
constructor (pattern, {
// Approximately where in the text is the pattern expected to be found?
location = 0,
// Determines how close the match must be to the fuzzy location (specified above).
// An exact letter match which is 'distance' characters away from the fuzzy location
// would score as a complete mismatch. A distance of '0' requires the match be at
// the exact location specified, a threshold of '1000' would require a perfect match
// to be within 800 characters of the fuzzy location to be found using a 0.8 threshold.
distance = 100,
// At what point does the match algorithm give up. A threshold of '0.0' requires a perfect match
// (of both letters and location), a threshold of '1.0' would match anything.
threshold = 0.6,
// Machine word size
maxPatternLength = 32,
// Indicates whether comparisons should be case sensitive.
isCaseSensitive = false,
// Regex used to separate words when searching. Only applicable when `tokenize` is `true`.
tokenSeparator = / +/g,
// When true, the algorithm continues searching to the end of the input even if a perfect
// match is found before the end of the same input.
findAllMatches = false,
// Minimum number of characters that must be matched before a result is considered a match
minMatchCharLength = 1
}) {
this.options = {
location,
distance,
threshold,
maxPatternLength,
isCaseSensitive,
tokenSeparator,
findAllMatches,
minMatchCharLength
}
this.pattern = this.options.isCaseSensitive ? pattern : pattern.toLowerCase()
if (this.pattern.length <= maxPatternLength) {
this.patternAlphabet = patternAlphabet(this.pattern)
}
}
search (text) {
if (!this.options.isCaseSensitive) {
text = text.toLowerCase()
}
// Exact match
if (this.pattern === text) {
return {
isMatch: true,
score: 0,
matchedIndices: [[0, text.length - 1]]
}
}
// When pattern length is greater than the machine word length, just do a a regex comparison
const { maxPatternLength, tokenSeparator } = this.options
if (this.pattern.length > maxPatternLength) {
return bitapRegexSearch(text, this.pattern, tokenSeparator)
}
// Otherwise, use Bitap algorithm
const { location, distance, threshold, findAllMatches, minMatchCharLength } = this.options
return bitapSearch(text, this.pattern, this.patternAlphabet, {
location,
distance,
threshold,
findAllMatches,
minMatchCharLength
})
}
}
// let x = new Bitap("od mn war", {})
// let result = x.search("Old Man's War")
// console.log(result)
export default Bitap

@ -0,0 +1,39 @@
const isArray = require('./is_array')
const deepValue = (obj, path, list) => {
if (!path) {
// If there's no path left, we've gotten to the object we care about.
list.push(obj)
} else {
const dotIndex = path.indexOf('.')
let firstSegment = path
let remaining = null
if (dotIndex !== -1) {
firstSegment = path.slice(0, dotIndex)
remaining = path.slice(dotIndex + 1)
}
const value = obj[firstSegment]
if (value !== null && value !== undefined) {
if (!remaining && (typeof value === 'string' || typeof value === 'number')) {
list.push(value.toString())
} else if (isArray(value)) {
// Search each item in the array.
for (let i = 0, len = value.length; i < len; i += 1) {
deepValue(value[i], remaining, list)
}
} else if (remaining) {
// An object. Recurse further.
deepValue(value, remaining, list)
}
}
}
return list
}
module.exports = (obj, path) => {
return deepValue(obj, path, [])
}

@ -0,0 +1 @@
module.exports = obj => !Array.isArray ? Object.prototype.toString.call(obj) === '[object Array]' : Array.isArray(obj)

414
src/helpers/fuse/index.js Normal file

@ -0,0 +1,414 @@
import Bitap from'./bitap';
const deepValue = require('./helpers/deep_value')
const isArray = require('./helpers/is_array')
class Fuse {
constructor (list, {
// Approximately where in the text is the pattern expected to be found?
location = 0,
// Determines how close the match must be to the fuzzy location (specified above).
// An exact letter match which is 'distance' characters away from the fuzzy location
// would score as a complete mismatch. A distance of '0' requires the match be at
// the exact location specified, a threshold of '1000' would require a perfect match
// to be within 800 characters of the fuzzy location to be found using a 0.8 threshold.
distance = 100,
// At what point does the match algorithm give up. A threshold of '0.0' requires a perfect match
// (of both letters and location), a threshold of '1.0' would match anything.
threshold = 0.6,
// Machine word size
maxPatternLength = 32,
// Indicates whether comparisons should be case sensitive.
caseSensitive = false,
// Regex used to separate words when searching. Only applicable when `tokenize` is `true`.
tokenSeparator = / +/g,
// When true, the algorithm continues searching to the end of the input even if a perfect
// match is found before the end of the same input.
findAllMatches = false,
// Minimum number of characters that must be matched before a result is considered a match
minMatchCharLength = 1,
// The name of the identifier property. If specified, the returned result will be a list
// of the items' dentifiers, otherwise it will be a list of the items.
id = null,
// List of properties that will be searched. This also supports nested properties.
keys = [],
// Whether to sort the result list, by score
shouldSort = true,
// The get function to use when fetching an object's properties.
// The default will search nested paths *ie foo.bar.baz*
getFn = deepValue,
// Default sort function
sortFn = (a, b) => (a.score - b.score),
// When true, the search algorithm will search individual words **and** the full string,
// computing the final score as a function of both. Note that when `tokenize` is `true`,
// the `threshold`, `distance`, and `location` are inconsequential for individual tokens.
tokenize = false,
// When true, the result set will only include records that match all tokens. Will only work
// if `tokenize` is also true.
matchAllTokens = false,
includeMatches = false,
includeScore = false,
// Will print to the console. Useful for debugging.
verbose = false
}) {
this.options = {
location,
distance,
threshold,
maxPatternLength,
isCaseSensitive: caseSensitive,
tokenSeparator,
findAllMatches,
minMatchCharLength,
id,
keys,
includeMatches,
includeScore,
shouldSort,
getFn,
sortFn,
verbose,
tokenize,
matchAllTokens
}
this.setCollection(list)
}
setCollection (list) {
this.list = list
return list
}
search (pattern) {
this._log(`---------\nSearch pattern: "${pattern}"`)
const {
tokenSearchers,
fullSearcher
} = this._prepareSearchers(pattern)
let { weights, results } = this._search(tokenSearchers, fullSearcher)
this._computeScore(weights, results)
if (this.options.shouldSort) {
this._sort(results)
}
return this._format(results)
}
_prepareSearchers (pattern = '') {
const tokenSearchers = []
if (this.options.tokenize) {
// Tokenize on the separator
const tokens = pattern.split(this.options.tokenSeparator)
for (let i = 0, len = tokens.length; i < len; i += 1) {
tokenSearchers.push(new Bitap(tokens[i], this.options))
}
}
let fullSearcher = new Bitap(pattern, this.options)
return { tokenSearchers, fullSearcher }
}
_search (tokenSearchers = [], fullSearcher) {
const list = this.list
const resultMap = {}
const results = []
// Check the first item in the list, if it's a string, then we assume
// that every item in the list is also a string, and thus it's a flattened array.
if (typeof list[0] === 'string') {
// Iterate over every item
for (let i = 0, len = list.length; i < len; i += 1) {
this._analyze({
key: '',
value: list[i],
record: i,
index: i
}, {
resultMap,
results,
tokenSearchers,
fullSearcher
})
}
return { weights: null, results }
}
// Otherwise, the first item is an Object (hopefully), and thus the searching
// is done on the values of the keys of each item.
const weights = {}
for (let i = 0, len = list.length; i < len; i += 1) {
let item = list[i]
// Iterate over every key
for (let j = 0, keysLen = this.options.keys.length; j < keysLen; j += 1) {
let key = this.options.keys[j]
if (typeof key !== 'string') {
weights[key.name] = {
weight: (1 - key.weight) || 1
}
if (key.weight <= 0 || key.weight > 1) {
throw new Error('Key weight has to be > 0 and <= 1')
}
key = key.name
} else {
weights[key] = {
weight: 1
}
}
this._analyze({
key,
value: this.options.getFn(item, key),
record: item,
index: i
}, {
resultMap,
results,
tokenSearchers,
fullSearcher
})
}
}
return { weights, results }
}
_analyze ({ key, arrayIndex = -1, value, record, index }, { tokenSearchers = [], fullSearcher = [], resultMap = {}, results = [] }) {
// Check if the texvaluet can be searched
if (value === undefined || value === null) {
return
}
let exists = false
let averageScore = -1
let numTextMatches = 0
if (typeof value === 'string') {
this._log(`\nKey: ${key === '' ? '-' : key}`)
let mainSearchResult = fullSearcher.search(value)
this._log(`Full text: "${value}", score: ${mainSearchResult.score}`)
if (this.options.tokenize) {
let words = value.split(this.options.tokenSeparator)
let scores = []
for (let i = 0; i < tokenSearchers.length; i += 1) {
let tokenSearcher = tokenSearchers[i]
this._log(`\nPattern: "${tokenSearcher.pattern}"`)
// let tokenScores = []
let hasMatchInText = false
for (let j = 0; j < words.length; j += 1) {
let word = words[j]
let tokenSearchResult = tokenSearcher.search(word)
let obj = {}
if (tokenSearchResult.isMatch) {
obj[word] = tokenSearchResult.score
exists = true
hasMatchInText = true
scores.push(tokenSearchResult.score)
} else {
obj[word] = 1
if (!this.options.matchAllTokens) {
scores.push(1)
}
}
this._log(`Token: "${word}", score: ${obj[word]}`)
// tokenScores.push(obj)
}
if (hasMatchInText) {
numTextMatches += 1
}
}
averageScore = scores[0]
let scoresLen = scores.length
for (let i = 1; i < scoresLen; i += 1) {
averageScore += scores[i]
}
averageScore = averageScore / scoresLen
this._log('Token score average:', averageScore)
}
let finalScore = mainSearchResult.score
if (averageScore > -1) {
finalScore = (finalScore + averageScore) / 2
}
this._log('Score average:', finalScore)
let checkTextMatches = (this.options.tokenize && this.options.matchAllTokens) ? numTextMatches >= tokenSearchers.length : true
this._log(`\nCheck Matches: ${checkTextMatches}`)
// If a match is found, add the item to <rawResults>, including its score
if ((exists || mainSearchResult.isMatch) && checkTextMatches) {
// Check if the item already exists in our results
let existingResult = resultMap[index]
if (existingResult) {
// Use the lowest score
// existingResult.score, bitapResult.score
existingResult.output.push({
key,
arrayIndex,
value,
score: finalScore,
matchedIndices: mainSearchResult.matchedIndices
})
} else {
// Add it to the raw result list
resultMap[index] = {
item: record,
output: [{
key,
arrayIndex,
value,
score: finalScore,
matchedIndices: mainSearchResult.matchedIndices
}]
}
results.push(resultMap[index])
}
}
} else if (isArray(value)) {
for (let i = 0, len = value.length; i < len; i += 1) {
this._analyze({
key,
arrayIndex: i,
value: value[i],
record,
index
}, {
resultMap,
results,
tokenSearchers,
fullSearcher
})
}
}
}
_computeScore (weights, results) {
this._log('\n\nComputing score:\n')
for (let i = 0, len = results.length; i < len; i += 1) {
const output = results[i].output
const scoreLen = output.length
let currScore = 1
let bestScore = 1
for (let j = 0; j < scoreLen; j += 1) {
let weight = weights ? weights[output[j].key].weight : 1
let score = weight === 1 ? output[j].score : (output[j].score || 0.001)
let nScore = score * weight
if (weight !== 1) {
bestScore = Math.min(bestScore, nScore)
} else {
output[j].nScore = nScore
currScore *= nScore
}
}
results[i].score = bestScore === 1 ? currScore : bestScore
this._log(results[i])
}
}
_sort (results) {
this._log('\n\nSorting....')
results.sort(this.options.sortFn)
}
_format (results) {
const finalOutput = []
if (this.options.verbose) {
this._log('\n\nOutput:\n\n', JSON.stringify(results))
}
let transformers = []
if (this.options.includeMatches) {
transformers.push((result, data) => {
const output = result.output
data.matches = []
for (let i = 0, len = output.length; i < len; i += 1) {
let item = output[i]
if (item.matchedIndices.length === 0) {
continue
}
let obj = {
indices: item.matchedIndices,
value: item.value
}
if (item.key) {
obj.key = item.key
}
if (item.hasOwnProperty('arrayIndex') && item.arrayIndex > -1) {
obj.arrayIndex = item.arrayIndex
}
data.matches.push(obj)
}
})
}
if (this.options.includeScore) {
transformers.push((result, data) => {
data.score = result.score
})
}
for (let i = 0, len = results.length; i < len; i += 1) {
const result = results[i]
if (this.options.id) {
result.item = this.options.getFn(result.item, this.options.id)[0]
}
if (!transformers.length) {
finalOutput.push(result.item)
continue
}
const data = {
item: result.item
}
for (let j = 0, len = transformers.length; j < len; j += 1) {
transformers[j](result, data)
}
finalOutput.push(data)
}
return finalOutput
}
_log () {
if (this.options.verbose) {
console.log(...arguments)
}
}
}
export default Fuse

@ -12,6 +12,7 @@ html, body {
}
#root {
position: relative;
display: flex;
flex-flow: column nowrap;
height: 100vh;
@ -19,4 +20,13 @@ html, body {
overflow-x: hidden;
overflow-y: auto;
background-color: $white;
z-index: 100;
-webkit-tap-highlight-color: rgba(255, 255, 255, 0);
}
#modal-root {
position: fixed;
top: 0;
left: 0;
z-index: 200;
}

@ -36,6 +36,7 @@ $wisteria-purple: #AE60B9;
color: $white;
outline: none;
border: 1px solid transparent;
user-select: none;
&:hover {
background-color: lighten($royal-blue, 5);
@ -46,3 +47,17 @@ $wisteria-purple: #AE60B9;
}
}
%borderless-input {
color: $mine-shaft-gray;
font-size: 1rem;
outline: none;
border: none;
flex: 1 1 auto;
width: 0;
&::placeholder {
color: $chalice-gray;
}
}

@ -2021,6 +2021,10 @@ center-align@^0.1.1:
align-text "^0.1.3"
lazy-cache "^1.0.3"
chain-function@^1.0.0:
version "1.0.1"
resolved "https://registry.yarnpkg.com/chain-function/-/chain-function-1.0.1.tgz#c63045e5b4b663fb86f1c6e186adaf1de402a1cc"
chalk@2.4.1, chalk@^2.0.1, chalk@^2.4.1:
version "2.4.1"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.1.tgz#18c49ab16a037b6eb0152cc83e3471338215b66e"
@ -2166,6 +2170,12 @@ cli-width@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639"
cli@0.4.3:
version "0.4.3"
resolved "https://registry.yarnpkg.com/cli/-/cli-0.4.3.tgz#e6819c8d5faa957f64f98f66a8506268c1d1f17d"
dependencies:
glob ">= 3.1.4"
cliui@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cliui/-/cliui-2.1.0.tgz#4b475760ff80264c762c3a1719032e91c7fea0d1"
@ -2285,7 +2295,7 @@ color@^3.0.0:
color-convert "^1.9.1"
color-string "^1.5.2"
colors@^1.1.2:
colors@>=0.6.x, colors@^1.1.2:
version "1.3.2"
resolved "https://registry.yarnpkg.com/colors/-/colors-1.3.2.tgz#2df8ff573dfbf255af562f8ce7181d6b971a359b"
@ -3412,6 +3422,10 @@ dom-converter@~0.1:
dependencies:
utila "~0.3"
dom-helpers@^3.2.0:
version "3.3.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-3.3.1.tgz#fc1a4e15ffdf60ddde03a480a9c0fece821dd4a6"
dom-serializer@0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-0.1.0.tgz#073c697546ce0780ce23be4a28e293e40bc30c82"
@ -4485,6 +4499,16 @@ functional-red-black-tree@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
fuse@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/fuse/-/fuse-0.4.0.tgz#2c38eaf888abb0a9ba7960cfe3339d1f3f53f6e6"
dependencies:
colors ">=0.6.x"
jshint "0.9.x"
optimist ">=0.3.5"
uglify-js ">=2.2.x"
underscore ">=1.4.x"
gauge@~2.7.3:
version "2.7.4"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
@ -4586,7 +4610,7 @@ glob-parent@^3.1.0:
is-glob "^3.1.0"
path-dirname "^1.0.0"
glob@^7.0.0, glob@~7.1.1, glob@~7.1.2:
"glob@>= 3.1.4", glob@^7.0.0, glob@~7.1.1, glob@~7.1.2:
version "7.1.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1"
dependencies:
@ -6148,6 +6172,13 @@ jsesc@~0.5.0:
version "0.5.0"
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
jshint@0.9.x:
version "0.9.1"
resolved "https://registry.yarnpkg.com/jshint/-/jshint-0.9.1.tgz#ff32ec7f09f84001f7498eeafd63c9e4fbb2dc0e"
dependencies:
cli "0.4.3"
minimatch "0.0.x"
json-parse-better-errors@^1.0.0, json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
@ -6559,6 +6590,10 @@ lru-cache@^4.1.1, lru-cache@^4.1.2, lru-cache@^4.1.3:
pseudomap "^1.0.2"
yallist "^2.1.2"
lru-cache@~1.0.2:
version "1.0.6"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-1.0.6.tgz#aa50f97047422ac72543bda177a9c9d018d98452"
make-dir@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.1.0.tgz#19b4369fe48c116f53c2af95ad102c0e39e85d51"
@ -6817,6 +6852,12 @@ minimalistic-crypto-utils@^1.0.0, minimalistic-crypto-utils@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a"
minimatch@0.0.x:
version "0.0.5"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.0.5.tgz#96bb490bbd3ba6836bbfac111adf75301b1584de"
dependencies:
lru-cache "~1.0.2"
minimatch@0.3:
version "0.3.0"
resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-0.3.0.tgz#275d8edaac4f1bb3326472089e7949c8394699dd"
@ -7572,7 +7613,7 @@ opn@^5.1.0:
dependencies:
is-wsl "^1.1.0"
optimist@^0.6.1:
optimist@>=0.3.5, optimist@^0.6.1:
version "0.6.1"
resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686"
dependencies:
@ -8623,7 +8664,7 @@ prop-types@^15.5.4, prop-types@^15.5.8, prop-types@^15.6.0:
loose-envify "^1.3.1"
object-assign "^4.1.1"
prop-types@^15.6.1, prop-types@^15.6.2:
prop-types@^15.5.6, prop-types@^15.6.1, prop-types@^15.6.2:
version "15.6.2"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.6.2.tgz#05d5ca77b4453e985d60fc7ff8c859094a497102"
dependencies:
@ -9006,6 +9047,16 @@ react-side-effect@^1.1.0:
exenv "^1.2.1"
shallowequal "^1.0.1"
react-transition-group@1.x:
version "1.2.1"
resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-1.2.1.tgz#e11f72b257f921b213229a774df46612346c7ca6"
dependencies:
chain-function "^1.0.0"
dom-helpers "^3.2.0"
loose-envify "^1.3.1"
prop-types "^15.5.6"
warning "^3.0.0"
react@^16.2.0:
version "16.2.0"
resolved "https://registry.yarnpkg.com/react/-/react-16.2.0.tgz#a31bd2dab89bff65d42134fa187f24d054c273ba"
@ -10761,6 +10812,13 @@ uglify-js@3.3.x:
commander "~2.13.0"
source-map "~0.6.1"
uglify-js@>=2.2.x:
version "3.4.9"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.4.9.tgz#af02f180c1207d76432e473ed24a28f4a782bae3"
dependencies:
commander "~2.17.1"
source-map "~0.6.1"
uglify-js@^2.6:
version "2.8.29"
resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-2.8.29.tgz#29c5733148057bb4e1f75df35b7a9cb72e6a59dd"
@ -10810,6 +10868,10 @@ underscore@1.8.3:
version "1.8.3"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.8.3.tgz#4f3fb53b106e6097fcf9cb4109f2a5e9bdfa5022"
underscore@>=1.4.x:
version "1.9.1"
resolved "https://registry.yarnpkg.com/underscore/-/underscore-1.9.1.tgz#06dce34a0e68a7babc29b365b8e74b8925203961"
unicode-canonical-property-names-ecmascript@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-1.0.4.tgz#2619800c4c825800efdd8343af7dd9933cbe2818"