diff --git a/package.json b/package.json index 991e611..b8eefbb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-datalist-input", - "version": "1.3.0", + "version": "1.3.1", "description": "This package provides a react component as follows: an input field with a drop down menu to pick a possible option based on the current input.", "main": "./lib/DataListInput.js", "license": "MIT", diff --git a/src/DataListInput.jsx b/src/DataListInput.jsx index 323f78f..c20d28b 100644 --- a/src/DataListInput.jsx +++ b/src/DataListInput.jsx @@ -1,342 +1,397 @@ -import React, { - useState, useRef, useEffect, useCallback, useMemo, - } from 'react'; - import PropTypes from 'prop-types'; - - import './DataListInput.css'; - - /** +import React from 'react'; +import PropTypes from 'prop-types'; + +import './DataListInput.css'; + +const windowExists = () => typeof window !== 'undefined'; + +class DataListInput extends React.Component { + constructor( props ) { + super( props ); + + const { initialValue } = this.props; + + this.state = { + /* last valid item that was selected from the drop down menu */ + lastValidItem: undefined, + /* current input text */ + currentInput: initialValue, + /* current set of matching items */ + matchingItems: [], + /* visibility property of the drop down menu */ + visible: false, + /* index of the currently focused item in the drop down menu */ + focusIndex: 0, + /* cleaner click events, click interaction within dropdown menu */ + interactionHappened: false, + /* show loader if still matching in debounced mode */ + isMatchingDebounced: false, + }; + + /* to manage debouncing of matching, typing input into the input field */ + this.inputHappenedTimeout = undefined; + } + + componentDidMount = () => { + if ( windowExists() ) { + window.addEventListener( 'click', this.onClickCloseMenu, false ); + } + } + + componentDidUpdate = () => { + const { currentInput, visible, isMatchingDebounced } = this.state; + const { initialValue } = this.props; + + // if we have an initialValue, we want to reset it everytime we update and are empty + // also setting a new initialValue will trigger this + if ( !currentInput && initialValue && !visible && !isMatchingDebounced ) { + this.setState( { currentInput: initialValue } ); + } + } + + componentWillUnmount = () => { + if ( windowExists() ) { + window.removeEventListener( 'click', this.onClickCloseMenu ); + } + } + + onClickCloseMenu = ( event ) => { + const menu = document.getElementsByClassName( 'datalist-items' ); + if ( !menu || !menu.length ) return; + // if rerender, items inside might change, allow one click without further checking + const { interactionHappened } = this.state; + if ( interactionHappened ) { + this.setState( { interactionHappened: false } ); + return; + } + // do not do anything if input is clicked, as we have a dedicated func for that + const input = document.getElementsByClassName( 'autocomplete-input' ); + if ( !input ) return; + for ( let i = 0; i < input.length; i += 1 ) { + const targetIsInput = event.target === input[ i ]; + const targetInInput = input[ i ].contains( event.target ); + if ( targetIsInput || targetInInput ) return; + } + + // do not close menu if user clicked inside + for ( let i = 0; i < menu.length; i += 1 ) { + const targetInMenu = menu[ i ].contains( event.target ); + const targetIsMenu = event.target === menu[ i ]; + if ( targetInMenu || targetIsMenu ) return; + } + const { visible } = this.state; + const { onDropdownClose } = this.props; + if ( visible ) { + this.setState( { visible: false, focusIndex: -1 }, onDropdownClose ); + } + } + + /** * default function for matching the current input value (needle) * and the values of the items array * @param currentInput * @param item * @returns {boolean} */ - const labelMatch = (currentInput, item) => item - .label.substr(0, currentInput.length).toLowerCase() === currentInput.toLowerCase(); - - /** + match = ( currentInput, item ) => item + .label.substr( 0, currentInput.length ).toUpperCase() === currentInput.toUpperCase(); + + /** + * matching process to find matching entries in items array + * @param currentInput + * @param item + * @param match + * @returns {Array} + */ + matching = ( currentInput, items, match ) => items.filter( ( item ) => { + if ( typeof ( match ) === typeof ( Function ) ) { return match( currentInput, item ); } + return this.match( currentInput, item ); + } ); + + /** * function for getting the index of the currentValue inside a value of the values array * @param currentInput * @param item * @returns {number} */ - const indexOfMatch = (currentInput, item) => item - .label.toLowerCase().indexOf(currentInput.toLowerCase()); - - /** - * index of item in items - * @param {*} item - * @param {*} items + indexOfMatch = ( currentInput, item ) => item + .label.toUpperCase().indexOf( currentInput.toUpperCase() ); + + indexOfItem = ( item, items ) => items.indexOf( items.find( i => i.key === item.key ) ) + + /** + * runs the matching process of the current input + * and handles debouncing the different callback calls to reduce lag time + * for bigger datasets or heavier matching algorithms + * @param currentInput */ - const indexOfItem = (item, items) => items - .indexOf(items.find(i => i.key === item.key)); - - const DataListInput = ({ - activeItemClassName, - clearInputOnSelect, - debounceLoader, - debounceTime, - dropdownClassName, - dropDownLength, - initialValue, - inputClassName, - itemClassName, - match, - onDropdownClose, - onDropdownOpen, - onInput, - onSelect, - placeholder, - requiredInputLength, - suppressReselect, - items, - }) => { - /* last valid item that was selected from the drop down menu */ - const [lastValidItem, setLastValidItem] = useState(); - /* current input text */ - const [currentInput, setCurrentInput] = useState(initialValue); - /* current set of matching items */ - const [matchingItems, setMatchingItems] = useState([]); - /* visibility property of the drop down menu */ - const [visible, setVisible] = useState(false); - /* index of the currently focused item in the drop down menu */ - const [focusIndex, setFocusIndex] = useState(0); - /* cleaner click events, click interaction within dropdown menu */ - const [interactionHappened, setInteractionHappened] = useState(false); - /* show loader if still matching in debounced mode */ - const [isMatchingDebounced, setIsMatchingDebounced] = useState(false); - - /* to manage debouncing of matching, typing input into the input field */ - const inputHappenedTimeout = useRef(); - const menu = useRef(); - const inputField = useRef(); - - useEffect(() => { - const onClickCloseMenu = (event) => { - if (!menu.current) return; - // if rerender, items inside might change, allow one click without further checking - if (interactionHappened) { - setInteractionHappened(false); - return; + debouncedMatchingUpdateStep = ( currentInput ) => { + const { lastValidItem } = this.state; + const { + items, match, debounceTime, dropDownLength, requiredInputLength, + clearInputOnSelect, onDropdownOpen, onDropdownClose, + } = this.props; + // cleanup waiting update step + if ( this.inputHappenedTimeout ) { + clearTimeout( this.inputHappenedTimeout ); } - // do not do anything if input is clicked, as we have a dedicated func for that - if (!inputField.current) return; - const targetIsInput = event.target === inputField.current; - const targetInInput = inputField.current.contains(event.target); - if (targetIsInput || targetInInput) return; - - // do not close menu if user clicked inside - const targetInMenu = menu.current.contains(event.target); - const targetIsMenu = event.target === menu.current; - if (targetInMenu || targetIsMenu) return; - - if (visible) { - setVisible(false); - setFocusIndex(-1); - onDropdownClose(); - } - }; - window.addEventListener('click', onClickCloseMenu, false); - return () => { - window.removeEventListener('click', onClickCloseMenu); - }; - }, [interactionHappened, onDropdownClose, visible]); - - useEffect(() => { - // if we have an initialValue, we want to reset it everytime we update and are empty - // also setting a new initialValue will trigger this - if (!currentInput && initialValue && !visible && !isMatchingDebounced) { - setCurrentInput(initialValue); - } - }, [currentInput, visible, isMatchingDebounced, initialValue]); - - /** - * runs the matching process of the current input - * and handles debouncing the different callback calls to reduce lag time - * for bigger datasets or heavier matching algorithms - * @param nextInput - */ - const debouncedMatchingUpdateStep = useCallback((nextInput) => { - // cleanup waiting update step - if (inputHappenedTimeout.current) { - clearTimeout(inputHappenedTimeout.current); - inputHappenedTimeout.current = null; - } - - // set nextInput into input field and show loading if debounced mode is on - const reachedRequiredLength = nextInput.length >= requiredInputLength; - const showMatchingStillLoading = debounceTime >= 0 && reachedRequiredLength; - setCurrentInput(nextInput); - setIsMatchingDebounced(showMatchingStillLoading); - // no matching if we do not reach required input length - if (!reachedRequiredLength) return; - - const updateMatchingItems = () => { - // matching process to find matching entries in items array - const updatedMatchingItems = items.filter((item) => { - if (typeof (match) === typeof (Function)) return match(nextInput, item); - return labelMatch(nextInput, item); - }); - const displayableItems = updatedMatchingItems.slice(0, dropDownLength); - const showDragIndex = lastValidItem && !clearInputOnSelect; - const index = showDragIndex ? indexOfItem(lastValidItem, displayableItems) : 0; - if (displayableItems.length) { - setMatchingItems(displayableItems); - setFocusIndex(index > 0 ? index : 0); - setIsMatchingDebounced(false); - setVisible(true); - onDropdownOpen(); + + // set currentInput into input field and show loading if debounced mode is on + const reachedRequiredLength = currentInput.length >= requiredInputLength; + const showMatchingStillLoading = debounceTime >= 0 && reachedRequiredLength; + this.setState( { currentInput, isMatchingDebounced: showMatchingStillLoading } ); + + // no matching if we do not reach required input length + if ( !reachedRequiredLength ) return; + + const updateMatchingItems = () => { + const matchingItems = this.matching( currentInput, items, match ); + const displayableItems = matchingItems.slice( 0, dropDownLength ); + const showDragIndex = lastValidItem && !clearInputOnSelect; + const index = showDragIndex ? this.indexOfItem( lastValidItem, displayableItems ) : 0; + if ( matchingItems.length > 0 ) { + this.setState( { + matchingItems: displayableItems, + focusIndex: index > 0 ? index : 0, + visible: true, + isMatchingDebounced: false, + }, onDropdownOpen ); + } else { + this.setState( { + matchingItems: displayableItems, + visible: false, + focusIndex: -1, + isMatchingDebounced: false, + }, onDropdownClose ); + } + }; + + if ( debounceTime <= 0 ) { + updateMatchingItems(); } else { - if (visible) { - setVisible(false); - onDropdownClose(); - } - setMatchingItems(displayableItems); - setFocusIndex(-1); - setIsMatchingDebounced(false); + this.inputHappenedTimeout = setTimeout( updateMatchingItems, debounceTime ); } - }; - - if (debounceTime <= 0) { - updateMatchingItems(); - } else { - inputHappenedTimeout.current = setTimeout(updateMatchingItems, debounceTime); - } - }, [requiredInputLength, debounceTime, match, items, - dropDownLength, lastValidItem, clearInputOnSelect, - onDropdownOpen, onDropdownClose, visible]); - + } + /** - * gets called when someone starts to write in the input field - * @param value - */ - const onHandleInput = useCallback((event) => { - const { value } = event.target; - debouncedMatchingUpdateStep(value); - onInput(value); - }, [debouncedMatchingUpdateStep, onInput]); - - const onClickInput = useCallback(() => { - let value = currentInput; - // if user clicks on input field with initialValue, - // the user most likely wants to clear the input field - if (initialValue && currentInput === initialValue) { - value = ''; - } - - const reachedRequiredLength = value.length >= requiredInputLength; - if (reachedRequiredLength && !visible) { - debouncedMatchingUpdateStep(value); - } - }, [visible, currentInput, requiredInputLength, initialValue, debouncedMatchingUpdateStep]); - - /** - * handleSelect is called onClickItem and onEnter upon an option of the drop down menu - * does nothing if the key has not changed since the last onSelect event - * @param selectedItem - */ - const onHandleSelect = useCallback((selectedItem) => { - // block select call until last matching went through - if (isMatchingDebounced) return; - - setCurrentInput(clearInputOnSelect ? '' : selectedItem.label); - setVisible(false); - setFocusIndex(-1); - setInteractionHappened(true); - onDropdownClose(); - - if (suppressReselect && lastValidItem && selectedItem.key === lastValidItem.key) { - // do not trigger the callback function - // but still change state to fit new selection - return; - } - // change state to fit new selection - setLastValidItem(selectedItem); - // callback function onSelect - onSelect(selectedItem); - }, [suppressReselect, clearInputOnSelect, onDropdownClose, - lastValidItem, isMatchingDebounced, onSelect]); - - /** - * handle key events - * @param event - */ - const onHandleKeydown = useCallback((event) => { - // only do something if drop-down div is visible - if (!visible) return; - let currentFocusIndex = focusIndex; - if (event.keyCode === 40 || event.keyCode === 9) { - // If the arrow DOWN key or tab is pressed increase the currentFocus variable: - currentFocusIndex += 1; - if (currentFocusIndex >= matchingItems.length) currentFocusIndex = 0; - setFocusIndex(currentFocusIndex); - // prevent tab to jump to the next input field if drop down is still open - event.preventDefault(); - } else if (event.keyCode === 38) { - // If the arrow UP key is pressed, decrease the currentFocus variable: - currentFocusIndex -= 1; - if (currentFocusIndex <= -1) currentFocusIndex = matchingItems.length - 1; - setFocusIndex(currentFocusIndex); - } else if (event.keyCode === 13) { - // Enter pressed, similar to onClickItem - if (focusIndex > -1) { - // Simulate a click on the "active" item: - const selectedItem = matchingItems[currentFocusIndex]; - onHandleSelect(selectedItem); + * gets called when someone starts to write in the input field + * @param value + */ + onHandleInput = ( event ) => { + const { onInput } = this.props; + const currentInput = event.target.value; + this.debouncedMatchingUpdateStep( currentInput ); + onInput( currentInput ); + }; + + onClickInput = () => { + const { visible } = this.state; + let { currentInput } = this.state; + const { requiredInputLength, initialValue } = this.props; + + // if user clicks on input field with initialValue, + // the user most likely wants to clear the input field + if ( initialValue && currentInput === initialValue ) { + this.setState( { currentInput: '' } ); + currentInput = ''; } - } - }, [visible, focusIndex, matchingItems, onHandleSelect]); - - const renderItemLabel = useCallback((item) => { - const index = indexOfMatch(currentInput, item); - const inputLength = currentInput.length; - return ( + + const reachedRequiredLength = currentInput.length >= requiredInputLength; + if ( reachedRequiredLength && !visible ) { + this.debouncedMatchingUpdateStep( currentInput ); + } + } + + /** + * handle key events + * @param event + */ + onHandleKeydown = ( event ) => { + const { visible, focusIndex, matchingItems } = this.state; + // only do something if drop-down div is visible + if ( !visible ) return; + let currentFocusIndex = focusIndex; + if ( event.keyCode === 40 || event.keyCode === 9 ) { + // If the arrow DOWN key or tab is pressed increase the currentFocus variable: + currentFocusIndex += 1; + if ( currentFocusIndex >= matchingItems.length ) currentFocusIndex = 0; + this.setState( { + focusIndex: currentFocusIndex, + } ); + // prevent tab to jump to the next input field if drop down is still open + event.preventDefault(); + } else if ( event.keyCode === 38 ) { + // If the arrow UP key is pressed, decrease the currentFocus variable: + currentFocusIndex -= 1; + if ( currentFocusIndex <= -1 ) currentFocusIndex = matchingItems.length - 1; + this.setState( { + focusIndex: currentFocusIndex, + } ); + } else if ( event.keyCode === 13 ) { + // Enter pressed, similar to onClickItem + if ( focusIndex > -1 ) { + // Simulate a click on the "active" item: + const selectedItem = matchingItems[ currentFocusIndex ]; + this.onSelect( selectedItem ); + } + } + }; + + /** + * onClickItem gets called when onClick happens on one of the list elements + * @param event + */ + onClickItem = ( event ) => { + const { matchingItems } = this.state; + // update the input value and close the dropdown again + const elements = event.currentTarget.children; + let selectedKey; + for ( let i = 0; i < elements.length; i += 1 ) { + if ( elements[ i ].tagName === 'INPUT' ) { + selectedKey = elements[ i ].value; + break; + } + } + // key can either be number or string + // eslint-disable-next-line eqeqeq + const selectedItem = matchingItems.find( item => item.key == selectedKey ); + this.onSelect( selectedItem ); + }; + + /** + * onSelect is called onClickItem and onEnter upon an option of the drop down menu + * does nothing if the key has not changed since the last onSelect event + * @param selectedItem + */ + onSelect = ( selectedItem ) => { + const { suppressReselect, clearInputOnSelect, onDropdownClose } = this.props; + const { lastValidItem, isMatchingDebounced } = this.state; + // block select call until last matching went through + if ( isMatchingDebounced ) return; + if ( suppressReselect && lastValidItem && selectedItem.key === lastValidItem.key ) { + // do not trigger the callback function + // but still change state to fit new selection + this.setState( { + currentInput: clearInputOnSelect ? '' : selectedItem.label, + visible: false, + focusIndex: -1, + interactionHappened: true, + }, onDropdownClose ); + return; + } + // change state to fit new selection + this.setState( { + currentInput: clearInputOnSelect ? '' : selectedItem.label, + lastValidItem: selectedItem, + visible: false, + focusIndex: -1, + interactionHappened: true, + }, onDropdownClose ); + // callback function onSelect + const { onSelect } = this.props; + onSelect( selectedItem ); + }; + + renderMatchingLabel = ( currentInput, item, indexOfMatch ) => ( - { index >= 0 && inputLength - // renders label with matching search string marked - ? ( - - {item.label.substr(0, index) } - - { item.label.substr(index, inputLength) } - - { item.label.substr(index + inputLength, item.label.length) } - - ) - : item.label } + {item.label.substr( 0, indexOfMatch ) } + + { item.label.substr( indexOfMatch, currentInput.length ) } + + { item.label.substr( indexOfMatch + currentInput.length, item.label.length ) } - ); - }, [currentInput]); - - const renderItems = useCallback(() => ( -
- {matchingItems.map((item, i) => { - const isActive = focusIndex === i; - const itemActiveClasses = isActive - ? `datalist-active-item ${activeItemClassName || 'datalist-active-item-default'}` : ''; - const itemClasses = `${itemClassName} ${itemActiveClasses}`; - return ( -
onHandleSelect(item)} - className={itemClasses} - key={item.key} - tabIndex={0} - role="button" - onKeyUp={event => event.preventDefault()} - > - {renderItemLabel(item)} -
- ); - })} -
- ), [dropdownClassName, matchingItems, focusIndex, - activeItemClassName, itemClassName, onHandleSelect, renderItemLabel]); - - const renderLoader = useCallback(() => ( -
-
{debounceLoader || 'loading...'}
-
- ), [dropdownClassName, itemClassName, debounceLoader]); - - const dropDown = useMemo(() => { - const reachedRequiredLength = currentInput.length >= requiredInputLength; - if (reachedRequiredLength && isMatchingDebounced) { - return renderLoader(); - } - if (reachedRequiredLength && visible) { - return renderItems(); - } - return undefined; - }, [currentInput, requiredInputLength, isMatchingDebounced, renderItems, renderLoader, visible]); - - - return ( -
- - { dropDown } -
); - }; - - DataListInput.propTypes = { + + renderItemLabel = ( currentInput, item, indexOfMatch ) => ( + + { indexOfMatch >= 0 && currentInput.length + ? this.renderMatchingLabel( currentInput, item, indexOfMatch ) + : item.label } + + ) + + renderItems = ( + currentInput, items, focusIndex, activeItemClassName, itemClassName, dropdownClassName, + ) => ( +
+ {items.map( ( item, i ) => { + const isActive = focusIndex === i; + const itemActiveClasses = isActive + ? `datalist-active-item ${ activeItemClassName || 'datalist-active-item-default' }` : ''; + const itemClasses = `${ itemClassName } ${ itemActiveClasses }`; + return ( +
event.preventDefault()} + > + {this.renderItemLabel( + currentInput, item, this.indexOfMatch( currentInput, item ), + )} + +
+ ); + } )} +
+ ); + + renderLoader = ( debounceLoader, dropdownClassName, itemClassName ) => ( +
+
{debounceLoader || 'loading...'}
+
+ ) + + renderInputField = ( placeholder, currentInput, inputClassName ) => ( + + ) + + render() { + const { + currentInput, matchingItems, focusIndex, visible, isMatchingDebounced, + } = this.state; + const { + placeholder, inputClassName, activeItemClassName, itemClassName, + requiredInputLength, dropdownClassName, debounceLoader, + } = this.props; + + const reachedRequiredLength = currentInput.length >= requiredInputLength; + + let renderedResults; + if ( reachedRequiredLength && isMatchingDebounced ) { + renderedResults = this.renderLoader( debounceLoader, itemClassName, dropdownClassName ); + } else if ( reachedRequiredLength && visible ) { + renderedResults = this.renderItems( currentInput, matchingItems, focusIndex, + activeItemClassName, itemClassName, dropdownClassName ); + } + return ( +
+ { this.renderInputField( placeholder, currentInput, inputClassName ) } + { renderedResults } +
+ ); + } +} + +DataListInput.propTypes = { items: PropTypes.arrayOf( - PropTypes.shape({ - label: PropTypes.string.isRequired, - key: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.number, - ]).isRequired, - }), + PropTypes.shape( { + label: PropTypes.string.isRequired, + key: PropTypes.oneOfType( [ + PropTypes.string, + PropTypes.number, + ] ).isRequired, + } ), ).isRequired, placeholder: PropTypes.string, onSelect: PropTypes.func.isRequired, @@ -355,9 +410,9 @@ import React, { debounceTime: PropTypes.number, debounceLoader: PropTypes.node, onInput: PropTypes.func, - }; - - DataListInput.defaultProps = { +}; + +DataListInput.defaultProps = { placeholder: '', match: undefined, inputClassName: '', @@ -374,7 +429,6 @@ import React, { onDropdownOpen: () => {}, onDropdownClose: () => {}, onInput: () => {}, - }; - - export default DataListInput; - \ No newline at end of file +}; + +export default DataListInput; \ No newline at end of file