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