Skip to content

Commit

Permalink
feat(select-field): add SelectField component
Browse files Browse the repository at this point in the history
  • Loading branch information
dackmin committed Jun 16, 2020
1 parent d6b3d3f commit baafdd0
Show file tree
Hide file tree
Showing 5 changed files with 367 additions and 16 deletions.
190 changes: 175 additions & 15 deletions lib/SelectField/index.js
Original file line number Diff line number Diff line change
@@ -1,59 +1,118 @@
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useReducer,
} from 'react';
import PropTypes from 'prop-types';

import { classNames, mockState } from '../utils';
import { useTimeout, useEventListener } from '../hooks';
import TextField from '../TextField';
import Dropdown from '../Dropdown';
import DropdownToggle from '../DropdownToggle';
import DropdownMenu from '../DropdownMenu';
import DropdownItem from '../DropdownItem';

const SelectField = forwardRef(({
search,
searchLabel,
animateMenu,
className,
label,
placeholder,
value,
autoFocus = false,
disabled = false,
noItems = 'No items found :(',
noSearchResults = 'No result found :(',
options = [],
required = false,
searchPlaceholder = 'Search...',
searchMinCharacters = 2,
searchThreshold = 400,
onBlur = () => {},
onChange = () => {},
onFocus = () => {},
onToggle = () => {},
parseTitle = val => val?.toString(),
parseTitle = val => val?.toString?.(),
parseValue = val => val,
validate = val => !required || typeof val !== 'undefined',
}, ref) => {
const innerRef = useRef();
const dropdownRef = useRef();
const fieldRef = useRef();
const searchFieldRef = useRef();
const [state, dispatch] = useReducer(mockState, {
value,
searchValue: '',
searchResults: null,
searching: false,
opened: false,
valid: false,
dirty: false,
selectedIndex: null,
});

useImperativeHandle(ref, () => ({
innerRef,
dropdownRef,
fieldRef,
searchFieldRef,
dirty: state.dirty,
internalValue: state.value,
valid: state.valie,
valid: state.valid,
opened: state.opened,
searchValue: state.searchValue,
searchResults: state.searchResults,
searching: state.searching,
focus,
blur,
reset,
}));

useEventListener('keydown', e => {
onSelectionChange_(e);
});

useEffect(() => {
if (disabled) {
dropdownRef.current?.close();
} else if (autoFocus) {
dropdownRef.current?.open();
}
}, [disabled]);

useEffect(() => {
if (value && options) {
dispatch({
value: options.find(o => parseValue(o) === parseValue(value)) || value,
valid: validate(parseValue(value)),
});
}
}, [value, options]);

useEffect(() => {
if (state.selectedIndex >= 0) {
dropdownRef.current?.innerRef.current?.querySelector(
`.dropdown-item:nth-child(${state.selectedIndex + 1})`
)?.focus();
}
}, [state.selectedIndex]);

useTimeout(() => {
search_();
}, searchThreshold, [state.searchValue]);

const onToggle_ = ({ opened }) => {
dispatch({ opened });
dispatch({
opened,
searchValue: '',
searchResults: null,
searching: false,
selectedIndex: null,
});
onToggle({ opened });
};

Expand All @@ -68,19 +127,57 @@ const SelectField = forwardRef(({
}
};

const onSelectionChange_ = e => {
if (e.key === 'ArrowUp' && state.opened) {
dispatch({
selectedIndex: (state.selectedIndex
? state.selectedIndex
: options.length
) - 1,
});
} else if (e.key === 'ArrowDown' && state.opened) {
dispatch({
selectedIndex: parseInt(state.selectedIndex, 10) < options.length - 1
? state.selectedIndex + 1
: 0,
});
}
};

const onChange_ = (option, e) => {
e.preventDefault();

if (disabled) {
return;
}

state.value = option;
state.valid = validate(parseValue(option));

dispatch({ value: state.value, dirty: true, valid: state.valie });
dispatch({ value: state.value, dirty: true, valid: state.valid });

dropdownRef.current?.close();
fieldRef.current?.setDirty(true);

onChange({ value: parseValue(state.value), valid: state.valid });
};

const onSearch_ = field =>
dispatch({ searchValue: field.value, searching: true });

const search_ = async () => {
if (!state.searchValue) {
dispatch({ searching: false, searchResults: null });
}

if (state.searchValue?.length < searchMinCharacters) {
return;
}

const results = await search(state.searchValue);
dispatch({ searchResults: results, searching: false });
};

const focus = () => {
fieldRef.current?.focus();
dropdownRef.current?.open();
Expand All @@ -96,18 +193,35 @@ const SelectField = forwardRef(({
value,
valid: false,
dirty: false,
searchValue: '',
searchResults: null,
searching: false,
});

fieldRef.current?.reset();
searchFieldRef.current?.reset();
};

const renderOption = (o, index) => (
<DropdownItem
key={index}
tabIndex={0}
onKeyPress={onSelect_.bind(null, o)}
>
<a href="#" onClick={onChange_.bind(null, o)} tabIndex={-1}>
{ parseTitle(o) }
</a>
</DropdownItem>
);

return (
<div
ref={innerRef}
className={classNames(
'junipero',
'field',
'select',
{ searching: state.searching },
className,
)}
>
Expand All @@ -128,24 +242,43 @@ const SelectField = forwardRef(({
/>
</DropdownToggle>
<DropdownMenu animate={animateMenu}>
{ options.map((o, index) => (
<DropdownItem
key={index}
tabIndex={0}
onKeyPress={onSelect_.bind(null, o)}
>
<a href="#" onClick={onChange_.bind(null, o)} tabIndex={-1}>
{ parseTitle(o) }
</a>
</DropdownItem>
)) }
{ search && (
<div className="search">
<TextField
ref={searchFieldRef}
disabled={disabled}
placeholder={searchPlaceholder}
label={searchLabel}
value={state.searchValue}
onChange={onSearch_}
/>
</div>
)}
{ state.searchResults ? (
<div className="search-results">
{ state.searchResults.length
? state.searchResults?.map((o, index) => renderOption(o, index))
: (
<div className="no-results">{ noSearchResults }</div>
) }
</div>
) : (
<div className="items">
{ options.length
? options.map((o, index) => renderOption(o, index))
: (
<div className="no-items">{ noItems }</div>
) }
</div>
) }
</DropdownMenu>
</Dropdown>
</div>
);
});

SelectField.propTypes = {
search: PropTypes.func,
autoFocus: PropTypes.bool,
animateMenu: PropTypes.func,
disabled: PropTypes.bool,
Expand All @@ -156,6 +289,18 @@ SelectField.propTypes = {
PropTypes.func,
PropTypes.bool,
]),
noItems: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
PropTypes.node,
PropTypes.func,
]),
noSearchResults: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
PropTypes.node,
PropTypes.func,
]),
options: PropTypes.array,
onBlur: PropTypes.func,
onChange: PropTypes.func,
Expand All @@ -170,6 +315,21 @@ SelectField.propTypes = {
PropTypes.func,
]),
required: PropTypes.bool,
searchPlaceholder: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
PropTypes.node,
PropTypes.func,
]),
searchLabel: PropTypes.oneOfType([
PropTypes.string,
PropTypes.object,
PropTypes.node,
PropTypes.func,
PropTypes.bool,
]),
searchMinCharacters: PropTypes.number,
searchThreshold: PropTypes.number,
validate: PropTypes.func,
value: PropTypes.any,
};
Expand Down
32 changes: 31 additions & 1 deletion lib/SelectField/index.stories.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
import { CSSTransition } from 'react-transition-group';

import SelectField from './index';

export default { title: 'SelectField' };

const options = ['One', 'Two', 'Three'];

const search = ['Four', 'Five', 'Six'];

const objectOptions = [
{ title: 'One', value: 1 },
{ title: 'Two', value: 2 },
Expand Down Expand Up @@ -42,12 +45,39 @@ export const withObjectOptionsAndValueEnforced = () => (

export const withPlaceholder = () => (
<SelectField
options={options}
placeholder="Select an item"
label="Chosen item"
onChange={action('change')}
/>
);

export const autoFocused = () => (
<SelectField autoFocus onChange={action('change')} />
<SelectField
options={options}
autoFocus
onChange={action('change')} />
);

export const withSearch = () => (
<SelectField
options={options}
search={val => search.filter(o => (new RegExp(val, 'ig')).test(o))}
onChange={action('change')} />
);

export const animated = () => (
<SelectField
options={options}
animateMenu={(menu, { opened }) => (
<CSSTransition
in={opened}
mountOnEnter={true}
unmountOnExit={true}
timeout={300}
classNames="slide-in-up-dropdown"
children={menu}
/>
)}
onChange={action('change')} />
);
13 changes: 13 additions & 0 deletions lib/SelectField/index.styl
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
@require "../theme/colors"

.junipero.select
.search
padding: 0 10px 10px

.text-input
min-width: auto
width: 100%

&.searching
.search-results, .items
opacity: .5
Loading

0 comments on commit baafdd0

Please sign in to comment.