Last active
September 24, 2018 10:18
-
-
Save Bitaru/511597c32dc3846f83461aa1ebc0a4dc to your computer and use it in GitHub Desktop.
Combobox
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import { omit, pickBy } from 'ramda'; | |
import { | |
compose, withHandlers, | |
withPropsOnChange, branch, defaultProps, | |
renderNothing, withStateHandlers, | |
withProps, mapProps, renameProp, shouldUpdate | |
} from 'recompose'; | |
// import createLabel from '../createLabel'; | |
import Downshift from 'downshift'; | |
import createLabel from '../createLabel'; | |
import Icon from 'components/Icon'; | |
import cx from 'classnames'; | |
import { Button as UIButton } from '../Button' | |
const styles = require('./styles.css'); | |
const pickItemsByValue = value => | |
pickBy(v => !value || v.toLowerCase().includes(value.toLowerCase())); | |
const getItems = compose( | |
branch( | |
({ multiple }) => multiple, | |
withPropsOnChange(['selectedItems', 'items'], ({ items, selectedItems }) => ({ | |
items: pickBy((_, k) => !selectedItems.includes(k))(items) | |
})) | |
), | |
branch( | |
({ searchable }) => !!searchable, | |
withPropsOnChange(['value', 'items'], ({ items, value }) => ({ | |
items: pickItemsByValue(value)(items) | |
})), | |
withPropsOnChange(['value', 'items'], ({ items, value }) => ({ | |
items: value | |
// pickBy(v => !v.toLowerCase().includes(value.toLowerCase()))(items) TODO: figure if new one is better | |
? pickBy((v, k) => Array.isArray(value) && !value.includes(k) || value !== k)(items) | |
: items | |
})), | |
) | |
); | |
const withState = withStateHandlers( | |
({ defaultValue, value }) => ({ search: '', selected: defaultValue || value || [] }), | |
{ | |
onSearch: (state, { onChangeSearch }) => search => { | |
if (onChangeSearch) onChangeSearch(search); | |
return { ...state, search }; | |
}, | |
addSelected: (state, { onChange }) => (item) => { | |
const selected = [...state.selected, item]; | |
if (state.selected.includes(item)) return { ...state, search: '' }; | |
onChange(selected); | |
return { search: '', selected }; | |
}, | |
setSelected: (state, { onChange }) => (selected) => { | |
onChange(selected) | |
return { ...state, selected } | |
} | |
} | |
); | |
const replaceDescription = (_, match) => `<em>(${match})</em>`; | |
const Item = ({ | |
text, | |
highlighted, | |
...itemProps | |
}) => ( | |
<button | |
{...itemProps} | |
className={cx(styles.item, highlighted && styles.highlighted)} | |
dangerouslySetInnerHTML={{ __html: text.replace(/\((.+?)\)/g, replaceDescription)}} | |
/> | |
); | |
const Items = compose( | |
branch(({ isOpen }) => !isOpen, renderNothing), | |
getItems, | |
)(({ | |
items, | |
itemProps, | |
highlightedIndex, | |
selectedItem | |
}: any) => !!Object.keys(items).length && ( | |
<div className={styles.items}> | |
{ | |
Object.keys(items).map((item, index) => ( | |
<Item | |
key={item} | |
highlighted={highlightedIndex === index} | |
text={items[item]} | |
{...itemProps({ item })} | |
/> | |
)) | |
} | |
</div> | |
)); | |
const Input = compose( | |
withHandlers({ | |
onFocus: ({ onSearch, onFocus }) => (e) => { | |
if (onSearch) onSearch(''); | |
return onFocus(e); | |
}, | |
onChange: ({ onSearch, onChange }) => (e) => { | |
if (onSearch) onSearch(e.target.value); | |
return onChange(e); | |
}, | |
onKeyDown: ({ onKeyDown, onCreate, ...props }) => (e) => { | |
if (onCreate && e.key.toLowerCase() === 'enter' && !props['aria-activedescendant']) { | |
onCreate(); | |
} | |
return onKeyDown(e); | |
} | |
}), | |
branch( | |
({ multiple, search }) => multiple && search !== undefined, | |
renameProp('search', 'value') | |
), | |
mapProps(omit(['onSearch', 'onCreate'])), | |
)(({ value, items, search, ...props} :any) => ( | |
<input | |
{...props} | |
className={styles.input} | |
value={( | |
items && items[value] || value || search || '' | |
).replace(/·/g, '').replace(/\((.+?)\)/g, '') | |
}/> | |
)); | |
const Button = ({ | |
theme, | |
value, | |
multiple, | |
placeholder, | |
...buttonProps | |
}) => ( | |
<button {...buttonProps} className={cx(styles.button, !value && styles.placeholder)}> | |
<span className={styles.truncate}> | |
{ value || placeholder } | |
</span> | |
</button> | |
); | |
export const Autocomplete = compose( | |
shouldUpdate((prev: any, next:any) => { | |
if (prev.options !== next.options) return true; | |
if (typeof prev.value === 'object') { | |
if (prev.value.length !== next.value.length) return true; | |
} else { | |
if (prev.value !== next.value) return true; | |
} | |
return false; | |
}), | |
defaultProps({ | |
placeholder: 'Select...' | |
}), | |
withProps(({ allowCreate }) => allowCreate && ({ | |
searchable: true | |
})), | |
withPropsOnChange(['options'], ({ options }) => ({ | |
items: Array.isArray(options) | |
? options.reduce((acc, item) => ({ ...acc, [String(item)]: String(item) }), {}) | |
: options || {} | |
})), | |
branch( | |
({ searchable, multiple, allowCreate }) => searchable || multiple || allowCreate, | |
withState | |
), | |
branch( | |
({ searchable, multiple, allowCreate }) => searchable || multiple || allowCreate, | |
withPropsOnChange(['value'], ({ value, setSelected, selected }) => value && value !== selected && setSelected(value) && ({})) | |
), | |
branch( | |
({ multiple }) => multiple, | |
withHandlers({ | |
onChange: ({ addSelected }) => addSelected, | |
onCreate: ({ addSelected, search }) => () => addSelected(search) | |
}) | |
), | |
withHandlers({ | |
onCreate: ({ onCreate, search }) => () => (search && search.trim().length > 0 && onCreate(search)) | |
}), | |
)(({ | |
searchable, // allow to search inside input | |
multiple, // allow to select multiple items | |
disabled, | |
allowCreate, | |
autoFocus, | |
hideArrow, | |
onChange, | |
onCreate, | |
onSearch, | |
placeholder, | |
items, | |
search, | |
selected, | |
icon, | |
value, | |
showAddButton, | |
defaultValue | |
}) => ( | |
<Downshift | |
onChange={onChange} | |
defaultInputValue={defaultValue ? String(defaultValue) : defaultValue} | |
inputValue={!searchable && !showAddButton ? value : undefined}> | |
{ | |
({ | |
getInputProps, | |
getItemProps, | |
getToggleButtonProps, | |
isOpen, | |
inputValue, | |
selectedItem, | |
highlightedIndex, | |
openMenu | |
}) => ( | |
<div className={cx(styles.autocomplete, isOpen && styles.focus)}> | |
{ icon } | |
{ | |
searchable && | |
<Input | |
{...getInputProps({ placeholder, disabled, autoFocus, onFocus: openMenu })} | |
onSearch={onSearch} | |
onCreate={allowCreate && onCreate} | |
search={search} | |
multiple={multiple} | |
items={items} | |
/> | |
} | |
{ | |
!searchable && | |
<Button | |
{...getToggleButtonProps({ disabled, placeholder })} | |
value={multiple ? undefined : (items[inputValue] || inputValue)} | |
/> | |
} | |
{ | |
!hideArrow && !!Object.keys(items).length && | |
<Icon name='arrow-down' width={24} height={24} className={styles.chevron}/> | |
} | |
{ | |
showAddButton && ( | |
<UIButton | |
className={styles.addItemButton} | |
type={(search && search.trim().length > 0 && 'primary') || 'secondary'} | |
onClick={allowCreate && onCreate}> | |
<Icon name='plus' width={14} height={14} /> | |
</UIButton> | |
) | |
} | |
<Items | |
isOpen={isOpen} | |
items={items} | |
itemProps={getItemProps} | |
multiple={multiple} | |
selectedItems={multiple && selected} | |
searchable={searchable} | |
selectedItem={selectedItem} | |
highlightedIndex={highlightedIndex} | |
value={search !== undefined ? search : inputValue} | |
/> | |
</div> | |
) | |
} | |
</Downshift> | |
)); | |
Autocomplete.displayName = 'Autocomplete' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
import { isString } from 'lodash'; | |
import { compose, withHandlers, withPropsOnChange, defaultProps, withProps, branch, flattenProp, withState } from 'recompose'; | |
import cx from 'classnames'; | |
import { memoize } from 'lodash'; | |
import Icon from 'components/Icon'; | |
import withRemoteOptions from './withRemoteOptions'; | |
import withField from '../withField'; | |
import createLabel from '../createLabel'; | |
import { Autocomplete } from './Autocomplete'; | |
import { Button } from '../Button'; | |
const styles = require('./styles.css'); | |
export type Props = { | |
disabled?: boolean, | |
inline?: boolean, | |
hideArrow?: boolean, | |
type?: string, | |
error?: string, | |
label?: string, | |
autoFocus?: boolean, | |
defaultValue?: string, | |
value?: string | number, | |
onFocus?: (FormEvent) => void, | |
onBlur?: (FormEvent) => void, | |
onChange?: (FormEvent) => void, | |
theme?: 'normal'|'rounded'|'dark', | |
size?: 'small'|'medium'|'large', | |
style?: any, | |
icon?: string, | |
placeholder?: string, | |
onEnter?: () => void, | |
onChangeSearch?: () => void, | |
multiple?: boolean, | |
searchable?: boolean, | |
allowCreate?: boolean, | |
options?: any[], | |
parse?: () => void, | |
normalize?: () => void, | |
showAddButton?: boolean | |
} | |
export type OwnProps = Props & { | |
theme: string, | |
size: string, | |
icon: JSX.Element | undefined | |
} | |
const getIcon = memoize( | |
name => name && <Icon name={name} className={styles.icon} width={24} height={24} /> | |
); | |
export const Select = compose<OwnProps, Props>( | |
defaultProps({ theme: 'normal', size: 'medium' }), | |
withProps(({ icon }) => ({ icon: getIcon(icon) })), | |
withPropsOnChange(['label'], ({ label, input }) => !!label && { | |
label: React.createElement( | |
isString(label) ? createLabel(label) : label, | |
{ className: styles.label, htmlFor: input && input.name } | |
) | |
}), | |
branch( | |
({ fetch }) => !!fetch, | |
withRemoteOptions | |
) | |
)(({ | |
disabled, | |
type, | |
error, | |
label, | |
defaultValue, | |
value, | |
placeholder, | |
options, | |
multiple, | |
searchable, | |
allowCreate, | |
autoFocus, | |
inline, | |
hideArrow, | |
onChangeSearch, | |
onChange, | |
onCreate, | |
theme, | |
size, | |
style, | |
icon, | |
showAddButton | |
}) => ( | |
<div | |
style={style} | |
className={cx( | |
styles.root, | |
styles[`theme-${theme}`], | |
styles[`size-${size}`], | |
error && styles.hasError, | |
disabled && styles.disabled, | |
inline && styles.inline | |
)} | |
> | |
{ label } | |
<div className={styles.wrapper}> | |
<Autocomplete | |
onChangeSearch={onChangeSearch} | |
autoFocus={autoFocus} | |
icon={icon} | |
hideArrow={hideArrow} | |
multiple={multiple} | |
searchable={searchable} | |
allowCreate={allowCreate} | |
disabled={disabled} | |
onChange={onChange} | |
onCreate={onCreate} | |
placeholder={placeholder} | |
defaultValue={value} | |
options={options} | |
showAddButton={showAddButton} | |
value={value} | |
/> | |
</div> | |
{ | |
error && | |
<div className={styles.error}>{error}</div> | |
} | |
</div> | |
)); | |
Select.displayName = 'Select' | |
export default withField(Select); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@import 'variables.new.css'; | |
.label{ | |
display: block; | |
margin-bottom: 5px; | |
font-weight: normal; | |
} | |
.wrapper{ | |
position: relative; | |
} | |
.icon{ | |
position: absolute; | |
left: 15px; | |
top: 50%; | |
margin-top: -12px; | |
z-index: 1; | |
} | |
.chevron{ | |
position: absolute; | |
top: 50%; | |
margin-top: -12px; | |
right: 15px; | |
z-index: 1; | |
color: $base; | |
transition: color .1s linear; | |
.focus &{ | |
color: $hightlight; | |
} | |
} | |
.size{ | |
&-medium { | |
.input, .button{ | |
height: 50px; | |
padding: 0 15px 0 19px; | |
padding-right: 52px; | |
} | |
} | |
&-small { | |
.input, .button{ | |
height: 40px; | |
padding: 0 15px; | |
padding-right: 52px; | |
} | |
} | |
} | |
.autocomplete{ | |
position: relative; | |
background: $white; | |
} | |
.input, .button{ | |
display: block; | |
position: relative; | |
width: 100%; | |
line-height: 1.2; | |
border-radius: 3px; | |
text-align: left; | |
z-index: 3; | |
background: transparent; | |
border: 1px solid $grey-2; | |
font-size: 14px; | |
color: $base; | |
transition: border-color .1s linear; | |
&:not(:first-child){ | |
padding-left: 52px; | |
} | |
.focus &{ | |
border-color: $hightlight; | |
} | |
&::placeholder, .placeholder &{ | |
font-size: 14px; | |
font-style: italic; | |
font-weight: 500; | |
color: $grey-4; | |
} | |
&:disabled, .disabled &{ | |
background-color: $grey; | |
color: $grey-4; | |
cursor: not-allowed; | |
} | |
.hasError &{ | |
border-color: $warning; | |
} | |
} | |
.truncate{ | |
display: inline-block; | |
max-height: 2.4em; | |
overflow: hidden; | |
width: 100%; | |
} | |
.items { | |
background-color: $white; | |
max-height: 0; | |
overflow-x: hidden; | |
overflow-y: scroll; | |
padding: 0; | |
position: absolute; | |
transition-duration: .1s; | |
transition-property: max-height; | |
transition-timing-function: ease-in-out; | |
visibility: hidden; | |
left: 0; | |
right: 0; | |
top: 100%; | |
margin-top: -3px; | |
z-index: 5; | |
border-bottom-left-radius: 5px; | |
border-bottom-right-radius: 5px; | |
border-top: 1px solid $hightlight; | |
font-size: 14px; | |
color: $base; | |
.focus &{ | |
max-height: 240px; | |
visibility: visible; | |
-ms-overflow-style:none; | |
border: 1px solid $hightlight; | |
border-top: none; | |
} | |
.hideOptions &{ | |
display: none; | |
} | |
.showAllOptions &{ | |
max-height: 1000px; | |
} | |
} | |
.item { | |
display: block; | |
border: none; | |
outline: none; | |
cursor: pointer; | |
width: 100%; | |
padding: 10px 15px; | |
position: relative; | |
text-align: left; | |
color: $base; | |
font-size: 15px; | |
border-radius: 0; | |
&:after{ | |
content: ' '; | |
position: absolute; | |
left: 5px; | |
right: 5px; | |
bottom: -1px; | |
height: 1px; | |
background-color: $grey; | |
} | |
&:last-child:after{ | |
display: none; | |
} | |
&.highlighted { | |
background-color: $grey; | |
} | |
} | |
.theme-{ | |
&dark{ | |
.autocomplete{ | |
background: $base-dark; | |
} | |
.input, .button{ | |
color: $white; | |
border-color: $base-darker; | |
} | |
.items{ | |
background-color: $base-dark; | |
} | |
.item{ | |
color: $white; | |
border-color: $base-darker; | |
} | |
.focus { | |
.item{ | |
border-color: $base-darker; | |
&:hover{ | |
border-color: $base-darker; | |
} | |
&:after{ | |
background-color: $base-darker; | |
} | |
} | |
.items{ | |
border-color: $base-darker; | |
} | |
.highlighted { | |
background-color: $base-darker; | |
} | |
} | |
} | |
} | |
.error { | |
color: $warning; | |
font-size: 11px; | |
font-weight: 600; | |
padding-top: 5px; | |
} | |
@media(min-width: 768px){ | |
.items::-webkit-scrollbar { | |
-webkit-appearance: none; | |
width: 7px; | |
} | |
.items::-webkit-scrollbar-thumb { | |
border-radius: 4px; | |
background-color: rgba(0,0,0,.5); | |
-webkit-box-shadow: 0 0 1px rgba(255,255,255,.5); | |
} | |
} | |
.addItemButton { | |
position: absolute; | |
top: 5px; | |
right: 5px; | |
display: inline-flex; | |
padding-left: 10px; | |
padding-right: 10px; | |
z-index: 3; | |
} | |
.inline{ | |
display: flex; | |
& .label{ | |
color: $base; | |
padding: 0 15px; | |
margin: 0; | |
background: #E9E9EC; | |
display: flex; | |
align-items: center; | |
border-top-left-radius: 3px; | |
border-bottom-left-radius: 3px; | |
} | |
& .input, & .button{ | |
border-top-left-radius: 0; | |
border-bottom-left-radius: 0; | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React from 'react'; | |
export default baseComponent => { | |
const Base = React.createFactory(baseComponent); | |
return class Remote extends React.Component<any, any>{ | |
state = { | |
options: {}, | |
disabled: false | |
} | |
handleChange(value) { | |
const { fetch } = this.props; | |
const fx = fetch(value); | |
if (!!fx.then) { | |
return fx | |
.then(options => this.setState({ options })) | |
.catch(() => this.setState({ disabled: true })) | |
} else { | |
return this.setState({ options: fx }) | |
} | |
} | |
componentWillMount() { | |
this.handleChange(this.props.value); | |
} | |
render() { | |
return Base({ ...this.props, ...this.state }) | |
} | |
} | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment