Skip to content

Instantly share code, notes, and snippets.

@Bitaru
Last active September 24, 2018 10:18
Show Gist options
  • Save Bitaru/511597c32dc3846f83461aa1ebc0a4dc to your computer and use it in GitHub Desktop.
Save Bitaru/511597c32dc3846f83461aa1ebc0a4dc to your computer and use it in GitHub Desktop.
Combobox
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'
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);
@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;
}
}
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