Loëck Vézien
7 years ago
committed by
GitHub
8 changed files with 369 additions and 6 deletions
@ -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<Object>, // 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<Object>, |
|||
} |
|||
|
|||
class Search extends PureComponent<Props, State> { |
|||
static defaultProps = { |
|||
fuseOptions: {}, |
|||
highlight: false, |
|||
filterEmpty: false, |
|||
renderHighlight: (chunk: string): * => <b>{chunk}</b>, |
|||
} |
|||
|
|||
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<Object>, 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 |
@ -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 }) => ( |
|||
<div> |
|||
<div style={{ opacity: 0.2 }}>{'(Change the search value in knobs)'}</div> |
|||
{children} |
|||
</div> |
|||
) |
|||
|
|||
Wrapper.propTypes = { |
|||
children: PropTypes.any.isRequired, |
|||
} |
|||
|
|||
stories.add('basic', () => { |
|||
const value = text('value', '') |
|||
const filterEmpty = boolean('filterEmpty', false) |
|||
return ( |
|||
<Wrapper> |
|||
<Search |
|||
value={value} |
|||
items={items} |
|||
filterEmpty={filterEmpty} |
|||
fuseOptions={{ |
|||
keys: ['name'], |
|||
}} |
|||
render={items => items.map(item => <div key={item.name}>{item.name}</div>)} |
|||
/> |
|||
</Wrapper> |
|||
) |
|||
}) |
|||
|
|||
stories.add('highlight matches', () => { |
|||
const value = text('value', '') |
|||
const filterEmpty = boolean('filterEmpty', false) |
|||
return ( |
|||
<Wrapper> |
|||
<Search |
|||
value={value} |
|||
items={items} |
|||
filterEmpty={filterEmpty} |
|||
highlight |
|||
fuseOptions={{ |
|||
keys: ['name'], |
|||
}} |
|||
renderHighlight={(text, key) => ( |
|||
<b key={key} style={{ textDecoration: 'underline', color: 'red' }}> |
|||
{text} |
|||
</b> |
|||
)} |
|||
render={items => |
|||
items.map(item => <div key={item.key}>{item.name_highlight || item.name}</div>) |
|||
} |
|||
/> |
|||
</Wrapper> |
|||
) |
|||
}) |
@ -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<Object>, |
|||
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<Props> { |
|||
render() { |
|||
const { items, itemToString, fuseOptions, highlight, renderHighlight, onChange } = this.props |
|||
return ( |
|||
<Downshift |
|||
itemToString={itemToString} |
|||
onChange={onChange} |
|||
render={({ |
|||
getInputProps, |
|||
getItemProps, |
|||
getRootProps, |
|||
isOpen, |
|||
inputValue, |
|||
highlightedIndex, |
|||
openMenu, |
|||
}) => ( |
|||
<Container {...getRootProps({ refKey: 'innerRef' })}> |
|||
<SearchInput |
|||
keepEvent |
|||
{...getInputProps({ placeholder: 'Chess?' })} |
|||
isOpen={isOpen} |
|||
onClick={openMenu} |
|||
/> |
|||
{isOpen && ( |
|||
<Search |
|||
value={inputValue} |
|||
items={items} |
|||
fuseOptions={fuseOptions} |
|||
highlight={highlight} |
|||
renderHighlight={renderHighlight} |
|||
render={items => |
|||
items.length ? ( |
|||
<Dropdown> |
|||
{items.map((item, i) => ( |
|||
<ItemWrapper key={item.key} {...getItemProps({ item })}> |
|||
<Item highlighted={i === highlightedIndex}> |
|||
<span>{item.name_highlight || item.name}</span> |
|||
</Item> |
|||
</ItemWrapper> |
|||
))} |
|||
</Dropdown> |
|||
) : null |
|||
} |
|||
/> |
|||
)} |
|||
</Container> |
|||
)} |
|||
/> |
|||
) |
|||
} |
|||
} |
|||
|
|||
export default Select |
@ -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', () => ( |
|||
<Select |
|||
items={items} |
|||
highlight |
|||
fuseOptions={{ keys: ['name'] }} |
|||
itemToString={item => (item ? item.name : '')} |
|||
renderHighlight={(text, key) => ( |
|||
<Text key={key} fontWeight="bold"> |
|||
{text} |
|||
</Text> |
|||
)} |
|||
/> |
|||
)) |
Loading…
Reference in new issue