diff --git a/lib/SelectField/index.js b/lib/SelectField/index.js
index 5d9672648..f90e4aca3 100644
--- a/lib/SelectField/index.js
+++ b/lib/SelectField/index.js
@@ -1,5 +1,6 @@
import React, {
forwardRef,
+ useEffect,
useImperativeHandle,
useRef,
useReducer,
@@ -7,6 +8,7 @@ import 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';
@@ -14,6 +16,8 @@ import DropdownMenu from '../DropdownMenu';
import DropdownItem from '../DropdownItem';
const SelectField = forwardRef(({
+ search,
+ searchLabel,
animateMenu,
className,
label,
@@ -21,39 +25,94 @@ const SelectField = forwardRef(({
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 });
};
@@ -68,12 +127,34 @@ 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);
@@ -81,6 +162,22 @@ const SelectField = forwardRef(({
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();
@@ -96,11 +193,27 @@ const SelectField = forwardRef(({
value,
valid: false,
dirty: false,
+ searchValue: '',
+ searchResults: null,
+ searching: false,
});
fieldRef.current?.reset();
+ searchFieldRef.current?.reset();
};
+ const renderOption = (o, index) => (
+
+
+ { parseTitle(o) }
+
+
+ );
+
return (
@@ -128,17 +242,35 @@ const SelectField = forwardRef(({
/>
- { options.map((o, index) => (
-
-
- { parseTitle(o) }
-
-
- )) }
+ { search && (
+
+
+
+ )}
+ { state.searchResults ? (
+
+ { state.searchResults.length
+ ? state.searchResults?.map((o, index) => renderOption(o, index))
+ : (
+
{ noSearchResults }
+ ) }
+
+ ) : (
+
+ { options.length
+ ? options.map((o, index) => renderOption(o, index))
+ : (
+
{ noItems }
+ ) }
+
+ ) }
@@ -146,6 +278,7 @@ const SelectField = forwardRef(({
});
SelectField.propTypes = {
+ search: PropTypes.func,
autoFocus: PropTypes.bool,
animateMenu: PropTypes.func,
disabled: PropTypes.bool,
@@ -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,
@@ -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,
};
diff --git a/lib/SelectField/index.stories.js b/lib/SelectField/index.stories.js
index 393fc57e9..c7720eabd 100644
--- a/lib/SelectField/index.stories.js
+++ b/lib/SelectField/index.stories.js
@@ -1,5 +1,6 @@
import React from 'react';
import { action } from '@storybook/addon-actions';
+import { CSSTransition } from 'react-transition-group';
import SelectField from './index';
@@ -7,6 +8,8 @@ export default { title: 'SelectField' };
const options = ['One', 'Two', 'Three'];
+const search = ['Four', 'Five', 'Six'];
+
const objectOptions = [
{ title: 'One', value: 1 },
{ title: 'Two', value: 2 },
@@ -42,6 +45,7 @@ export const withObjectOptionsAndValueEnforced = () => (
export const withPlaceholder = () => (
(
);
export const autoFocused = () => (
-
+
+);
+
+export const withSearch = () => (
+ search.filter(o => (new RegExp(val, 'ig')).test(o))}
+ onChange={action('change')} />
+);
+
+export const animated = () => (
+ (
+
+ )}
+ onChange={action('change')} />
);
diff --git a/lib/SelectField/index.styl b/lib/SelectField/index.styl
new file mode 100644
index 000000000..39995c9ef
--- /dev/null
+++ b/lib/SelectField/index.styl
@@ -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
diff --git a/lib/SelectField/index.test.js b/lib/SelectField/index.test.js
new file mode 100644
index 000000000..7008a5466
--- /dev/null
+++ b/lib/SelectField/index.test.js
@@ -0,0 +1,147 @@
+import React, { createRef } from 'react';
+import { mount } from 'enzyme';
+import sinon from 'sinon';
+import { act } from 'react-dom/test-utils';
+
+import SelectField from './';
+
+describe('', () => {
+ const options = ['One', 'Two', 'Three', 'Four'];
+
+ it('should render', () => {
+ const ref = createRef();
+ const component = mount();
+ component.find('input').simulate('focus');
+ act(() => { ref.current.reset(); });
+ component.find('input').simulate('blur');
+ expect(component.find('.junipero.select').length).toBe(1);
+ });
+
+ it('should initialize if value prop is defined on mount', () => {
+ const ref = createRef();
+ mount();
+ expect(ref.current.internalValue).toBe('One');
+ });
+
+ it('should update internal value when value prop changes', () => {
+ const ref = createRef();
+ const component = mount(
+
+ );
+ expect(ref.current.internalValue).toBe('One');
+ component.setProps({ value: 'Two' });
+ expect(ref.current.internalValue).toBe('Two');
+ });
+
+ it('should close menu when disabled prop changes', () => {
+ const ref = createRef();
+ const component = mount();
+ component.find('input').simulate('focus');
+ expect(ref.current.opened).toBe(true);
+ component.setProps({ disabled: true });
+ expect(ref.current.opened).toBe(false);
+ });
+
+ it('should reset internal value when calling reset method for native', () => {
+ const ref = createRef();
+ const component = mount(
+
+ );
+ component.find('input').simulate('focus');
+ component.find('.dropdown-item').at(1).find('a').simulate('click');
+ expect(ref.current.internalValue).toBe('Two');
+ act(() => { ref.current.reset(); });
+ expect(ref.current.internalValue).toBe('One');
+ });
+
+ it('should search for items on search field change', async () => {
+ const search = sinon.spy(() => ['Three']);
+ jest.useFakeTimers();
+
+ const component = mount(
+
+ );
+ component.find('.search input').simulate('change', {
+ target: { value: 'test' },
+ });
+
+ await act(async () => { jest.runAllTimers(); });
+ expect(search.calledOnce).toBe(true);
+ expect(search.calledWith(sinon.match('test'))).toBe(true);
+ });
+
+ it('shouldn\'t call search callback when search value is not set or ' +
+ 'empty', async () => {
+ const search = sinon.spy(() => ['Three']);
+ jest.useFakeTimers();
+
+ const component = mount(
+
+ );
+ component.find('.search input').simulate('change', {
+ target: { value: '' },
+ });
+
+ await act(async () => { jest.runAllTimers(); });
+ expect(search.called).toBe(false);
+ });
+
+ it('should fire onToggle event when opened/closed', () => {
+ const onToggle = sinon.spy();
+ const ref = createRef();
+ mount(
+
+ );
+ act(() => { ref.current.focus(); });
+ expect(ref.current.opened).toBe(true);
+ expect(onToggle.calledWith(sinon.match({ opened: true }))).toBe(true);
+ act(() => { ref.current.blur(); });
+ expect(ref.current.opened).toBe(false);
+ expect(onToggle.calledWith(sinon.match({ opened: false }))).toBe(true);
+ });
+
+ it('should set a custom text if options aren\'t provided or empty', () => {
+ const component = mount(
+
+ );
+ expect(component.find('.dropdown-menu').find('div.no-items').html())
+ .toBe('There is no data here.
');
+ });
+
+ it('should accept a value not included in provided options and' +
+ ' set it as first index', () => {
+ const ref = createRef();
+ const component = mount(
+
+ );
+ expect(ref.current.internalValue).toBe('Five');
+ component.find('.dropdown-item').at(0).find('a').simulate('click');
+ expect(ref.current.internalValue).toBe('One');
+ });
+
+ it('should update internal value when options change', () => {
+ const ref = createRef();
+ const component = mount(
+ o.value || o}
+ />
+ );
+
+ expect(ref.current.internalValue).toBe(5);
+
+ component.setProps({
+ value: 4,
+ options: [
+ { title: 'Four', value: 4 },
+ { title: 'Five', value: 5 },
+ { title: 'Six', value: 6 },
+ ],
+ });
+
+ expect(ref.current.internalValue?.value).toBe(4);
+ });
+
+});
diff --git a/lib/index.styl b/lib/index.styl
index 53106b225..06a75a026 100644
--- a/lib/index.styl
+++ b/lib/index.styl
@@ -11,6 +11,7 @@
@import "./DropdownMenu"
@import "./DropdownItem"
@import "./Modal"
+@import "./SelectField"
@import "./TextField"
.junipero