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 ) => (