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