diff --git a/.eslintrc b/.eslintrc index 33705c90..83571c3b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -18,10 +18,12 @@ "no-shadow": 0, "no-underscore-dangle": 0, "no-void": 0, + "no-plusplus": 0, "import/no-extraneous-dependencies": 0, "react/jsx-curly-brace-presence": 0, "react/jsx-filename-extension": 0, "react/prefer-stateless-function": 0, + "react/forbid-prop-types": 0, }, "settings": { "import/resolver": { diff --git a/package.json b/package.json index a07c884f..d2938557 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,10 @@ "@ledgerhq/hw-transport": "^1.1.2-beta.068e2a14", "@ledgerhq/hw-transport-node-hid": "^1.1.2-beta.068e2a14", "color": "^2.0.1", + "downshift": "^1.25.0", "electron-store": "^1.3.0", "electron-updater": "^2.18.2", + "fuse.js": "^3.2.0", "history": "^4.7.2", "i18next": "^10.2.2", "i18next-node-fs-backend": "^1.0.0", diff --git a/src/components/base/Input/index.js b/src/components/base/Input/index.js index e6dca1cf..582010b2 100644 --- a/src/components/base/Input/index.js +++ b/src/components/base/Input/index.js @@ -3,9 +3,12 @@ import React, { PureComponent } from 'react' import styled from 'styled-components' +import { space } from 'styled-system' -const Base = styled.input` - padding: 10px 15px; +const Base = styled.input.attrs({ + p: 2, +})` + ${space}; border: 1px solid ${p => p.theme.colors.mouse}; border-radius: 3px; display: flex; @@ -24,17 +27,22 @@ const Base = styled.input` ` type Props = { - onChange: Function, + onChange?: Function, + keepEvent?: boolean, } -export default class Input extends PureComponent { +class Input extends PureComponent { handleChange = (e: SyntheticInputEvent) => { - const { onChange } = this.props + const { onChange, keepEvent } = this.props - onChange(e.target.value) + if (onChange) { + onChange(keepEvent ? e : e.target.value) + } } render() { return } } + +export default Input diff --git a/src/components/base/Search/index.js b/src/components/base/Search/index.js new file mode 100644 index 00000000..f1945bb5 --- /dev/null +++ b/src/components/base/Search/index.js @@ -0,0 +1,128 @@ +// @flow + +import React, { PureComponent, Fragment, createElement } from 'react' +import Fuse from 'fuse.js' + +import type { Element } from 'react' +import type FuseType from 'fuse.js' + +// eslint false positive detection on unused prop-type +type Props = { + items: Array, // eslint-disable-line react/no-unused-prop-types + value: string, + render: Function, + highlight?: boolean, + renderHighlight?: (string, string) => Element<*>, // eslint-disable-line react/no-unused-prop-types + fuseOptions?: Object, // eslint-disable-line react/no-unused-prop-types + + // if true, it will display no items when value is empty + filterEmpty?: boolean, +} + +type State = { + results: Array, +} + +class Search extends PureComponent { + static defaultProps = { + fuseOptions: {}, + highlight: false, + filterEmpty: false, + renderHighlight: (chunk: string): * => {chunk}, + } + + state = { + results: [], + } + + componentWillMount() { + this.initFuse(this.props) + } + + componentWillReceiveProps(nextProps: Props) { + if (nextProps.value !== this.props.value) { + if (this._fuse) { + this.formatResults(this._fuse.search(nextProps.value), nextProps) + } + } + if (nextProps.highlight !== this.props.highlight) { + this.initFuse(nextProps) + } + if (nextProps.items !== this.props.items) { + this.initFuse(nextProps) + } + } + + _fuse: FuseType<*> | null = null + + initFuse(props: Props) { + const { fuseOptions, highlight, items, value } = props + + this._fuse = new Fuse(items, { + ...fuseOptions, + includeMatches: highlight, + }) + + this.formatResults(this._fuse.search(value), props) + } + + formatResults(results: Array, props: Props) { + const { highlight, renderHighlight } = props + if (highlight) { + results = results.map(res => { + let { item } = res + const { matches } = res + + matches.forEach(match => { + const { key, value, indices } = match + let i = 0 + const res = [] + + indices.forEach(idx => { + const [start, end] = idx + + const prefix = value.substring(i, start) + if (prefix.length > 0) { + res.push(prefix) + } + + const v = value.substring(start, end + 1) + if (v && renderHighlight) { + res.push(renderHighlight(v, `${key}-${idx.join(',')}`)) + } + + i = end + 1 + }) + + const suffix = value.substring(indices[indices.length - 1][1] + 1) + if (suffix.length > 0) { + res.push(suffix) + } + + const fragment = createElement(Fragment, { + key: item[key], + children: res, + }) + + item = { + ...item, + [`${key}_highlight`]: fragment, + } + }) + return item + }) + } + this.setState({ results }) + } + + render() { + const { render, value, items, filterEmpty } = this.props + const { results } = this.state + if (!filterEmpty && value === '') { + return render(items) + } + return render(results) + } +} + +export default Search diff --git a/src/components/base/Search/stories.js b/src/components/base/Search/stories.js new file mode 100644 index 00000000..8c3bf388 --- /dev/null +++ b/src/components/base/Search/stories.js @@ -0,0 +1,77 @@ +import React from 'react' +import PropTypes from 'prop-types' +import { storiesOf } from '@storybook/react' +import { text, boolean } from '@storybook/addon-knobs' + +import Search from 'components/base/Search' + +const stories = storiesOf('Search', module) + +const items = [ + { key: 'aleksandr-grichtchouk', name: 'Aleksandr Grichtchouk' }, + { key: 'fabiano-caruana', name: 'Fabiano Caruana' }, + { key: 'garry-kasparov', name: 'Garry Kasparov' }, + { key: 'hikaru-nakamura', name: 'Hikaru Nakamura' }, + { key: 'levon-aronian', name: 'Levon Aronian' }, + { key: 'magnus-carlsen', name: 'Magnus Carlsen' }, + { key: 'maxime-vachier-lagrave', name: 'Maxime Vachier-Lagrave' }, + { key: 'shakhriyar-mamedyarov', name: 'Shakhriyar Mamedyarov' }, + { key: 'veselin-topalov', name: 'Veselin Topalov' }, + { key: 'viswanathan-anand', name: 'Viswanathan Anand' }, + { key: 'vladimir-kramnik', name: 'Vladimir Kramnik' }, +] + +const Wrapper = ({ children }) => ( +
+
{'(Change the search value in knobs)'}
+ {children} +
+) + +Wrapper.propTypes = { + children: PropTypes.any.isRequired, +} + +stories.add('basic', () => { + const value = text('value', '') + const filterEmpty = boolean('filterEmpty', false) + return ( + + items.map(item =>
{item.name}
)} + /> +
+ ) +}) + +stories.add('highlight matches', () => { + const value = text('value', '') + const filterEmpty = boolean('filterEmpty', false) + return ( + + ( + + {text} + + )} + render={items => + items.map(item =>
{item.name_highlight || item.name}
) + } + /> +
+ ) +}) diff --git a/src/components/base/Select/index.js b/src/components/base/Select/index.js new file mode 100644 index 00000000..ae98ff35 --- /dev/null +++ b/src/components/base/Select/index.js @@ -0,0 +1,107 @@ +// @flow + +import React, { PureComponent } from 'react' +import Downshift from 'downshift' +import styled from 'styled-components' + +import type { Element } from 'react' + +import Box from 'components/base/Box' +import Input from 'components/base/Input' +import Search from 'components/base/Search' + +type Props = { + items: Array, + itemToString: Function, + onChange: Function, + fuseOptions?: Object, + highlight?: boolean, + renderHighlight?: string => Element<*>, +} + +const Container = styled(Box).attrs({ relative: true, color: 'steel' })`` + +const SearchInput = styled(Input)` + border-bottom-left-radius: ${p => (p.isOpen ? 0 : '')}; + border-bottom-right-radius: ${p => (p.isOpen ? 0 : '')}; +` + +const Item = styled(Box).attrs({ + p: 2, +})` + background: ${p => (p.highlighted ? p.theme.colors.cream : p.theme.colors.white)}; +` + +const ItemWrapper = styled(Box)` + & + & { + border-top: 1px solid ${p => p.theme.colors.mouse}; + } +` + +const Dropdown = styled(Box)` + position: absolute; + top: 100%; + left: 0; + right: 0; + border: 1px solid ${p => p.theme.colors.mouse}; + border-top: none; + max-height: 300px; + overflow-y: auto; + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; + box-shadow: rgba(0, 0, 0, 0.05) 0 2px 2px; +` + +class Select extends PureComponent { + render() { + const { items, itemToString, fuseOptions, highlight, renderHighlight, onChange } = this.props + return ( + ( + + + {isOpen && ( + + items.length ? ( + + {items.map((item, i) => ( + + + {item.name_highlight || item.name} + + + ))} + + ) : null + } + /> + )} + + )} + /> + ) + } +} + +export default Select diff --git a/src/components/base/Select/stories.js b/src/components/base/Select/stories.js new file mode 100644 index 00000000..8c35a4fe --- /dev/null +++ b/src/components/base/Select/stories.js @@ -0,0 +1,35 @@ +import React from 'react' +import { storiesOf } from '@storybook/react' + +import Select from 'components/base/Select' +import Text from 'components/base/Text' + +const stories = storiesOf('Select', module) + +const items = [ + { key: 'aleksandr-grichtchouk', name: 'Aleksandr Grichtchouk' }, + { key: 'fabiano-caruana', name: 'Fabiano Caruana' }, + { key: 'garry-kasparov', name: 'Garry Kasparov' }, + { key: 'hikaru-nakamura', name: 'Hikaru Nakamura' }, + { key: 'levon-aronian', name: 'Levon Aronian' }, + { key: 'magnus-carlsen', name: 'Magnus Carlsen' }, + { key: 'maxime-vachier-lagrave', name: 'Maxime Vachier-Lagrave' }, + { key: 'shakhriyar-mamedyarov', name: 'Shakhriyar Mamedyarov' }, + { key: 'veselin-topalov', name: 'Veselin Topalov' }, + { key: 'viswanathan-anand', name: 'Viswanathan Anand' }, + { key: 'vladimir-kramnik', name: 'Vladimir Kramnik' }, +] + +stories.add('basic', () => ( +