From 48bf055334f1e20720893d0d5b95e47043bdf2ee Mon Sep 17 00:00:00 2001 From: andrelandgraf Date: Tue, 28 Apr 2020 08:55:57 +0200 Subject: [PATCH] refactoring to functional component, and added onIput to type def --- testing/demo-app/.eslintrc | 14 +- testing/demo-app/src/App.js | 147 ++--- testing/demo-app/src/DataListInput.jsx | 754 ++++++++++++------------- testing/demo-app/src/index.js | 2 +- 4 files changed, 429 insertions(+), 488 deletions(-) diff --git a/testing/demo-app/.eslintrc b/testing/demo-app/.eslintrc index 7559803..c5857d5 100644 --- a/testing/demo-app/.eslintrc +++ b/testing/demo-app/.eslintrc @@ -9,18 +9,6 @@ "react" ], "rules": { - "indent": ["error", 4], - "react/jsx-indent": ["error", 4], - "react/jsx-indent-props": ["error", 4], - "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }], - "space-in-parens": [ 2, "always" ], - "template-curly-spacing": [ 2, "always" ], - "array-bracket-spacing": [ 2, "always" ], - "object-curly-spacing": [ 2, "always" ], - "computed-property-spacing": [ 2, "always" ], - "space-infix-ops": [2, {"int32Hint": false}], - "react/prop-types": [2], - "react/require-default-props": [2], - "no-underscore-dangle": [ "error", { "allow": [ "_id" ] } ] + "react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }] } } \ No newline at end of file diff --git a/testing/demo-app/src/App.js b/testing/demo-app/src/App.js index fb77db1..c37051e 100644 --- a/testing/demo-app/src/App.js +++ b/testing/demo-app/src/App.js @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ import React, { useState, useEffect } from 'react'; import csvFile from './data.csv'; @@ -6,96 +7,96 @@ import DataListInput from './DataListInput'; // eslint-disable-next-line no-unused-vars const data = [ - { - key: '0', - label: 'Apple', - }, - { - key: '1', - label: 'Mango', - }, - { - key: '2', - label: 'Potatoe', - }, + { + key: '0', + label: 'Apple', + }, + { + key: '1', + label: 'Mango', + }, + { + key: '2', + label: 'Potatoe', + }, ]; -async function getAndParseData( filename ) { - const response = await fetch( filename ); - const reader = response.body.getReader(); - const decoder = new TextDecoder( 'utf-8' ); - const result = await reader.read(); - const allText = decoder.decode( result.value ); +async function getAndParseData(filename) { + const response = await fetch(filename); + const reader = response.body.getReader(); + const decoder = new TextDecoder('utf-8'); + const result = await reader.read(); + const allText = decoder.decode(result.value); - const allTextLines = allText.split( /\r\n|\n/ ); - const headers = allTextLines[ 0 ].split( ',' ); - const lines = []; + const allTextLines = allText.split(/\r\n|\n/); + const headers = allTextLines[0].split(','); + const lines = []; - for ( let i = 1; i < allTextLines.length; i += 1 ) { - const set = allTextLines[ i ].split( ',' ); - if ( set.length === headers.length ) { - const tarr = {}; - for ( let j = 0; j < headers.length; j += 1 ) { - tarr[ headers[ j ] ] = set[ j ]; - } - lines.push( tarr ); - } + for (let i = 1; i < allTextLines.length; i += 1) { + const set = allTextLines[i].split(','); + if (set.length === headers.length) { + const tarr = {}; + for (let j = 0; j < headers.length; j += 1) { + tarr[headers[j]] = set[j]; + } + lines.push(tarr); } - return lines; + } + return lines; } // eslint-disable-next-line no-unused-vars -function annoyinglySlowMatchingAlg( currentInput, item ) { - for ( let i = 0; i < 100000; i += 1 ) { - i += 1; - i -= 1; - // eslint-disable-next-line no-unused-expressions - ( currentInput.length + item.label.length ) % 2; - } - return item.label.substr( 0, currentInput.length ).toUpperCase() === currentInput.toUpperCase(); +function annoyinglySlowMatchingAlg(currentInput, item) { + for (let i = 0; i < 100000; i += 1) { + i += 1; + i -= 1; + // eslint-disable-next-line no-unused-expressions + (currentInput.length + item.label.length) % 2; + } + return item.label.substr(0, currentInput.length).toUpperCase() === currentInput.toUpperCase(); } function App() { - const [ item, setItem ] = useState(); - const [ items, setItems ] = useState( [] ); + const [item, setItem] = useState(); + const [items, setItems] = useState(data); - useEffect( () => { - getAndParseData( csvFile ).then( obj => setItems( obj - .concat( obj ) - .map( ( row, i ) => ( - { - ...row, - label: row.vorname, - key: i, - } - ) ) ) ); - }, [] ); + // useEffect(() => { + // getAndParseData(csvFile).then(obj => setItems(obj + // .concat(obj) + // .map((row, i) => ( + // { + // ...row, + // label: row.vorname, + // key: i, + // } + // )))); + // }, []); - return ( -
-
- { + return ( +
+
+ { item && ( -
- { `Current Item: ${ item.label }` } -
+
+ { `Current Item: ${item.label}` } +
) } -
- setItem( i )} - placeholder="Select a ingredient" - clearInputOnSelect={false} - suppressReselect={false} - initialValue={item ? item.label : ''} - debounceTime={1000} - debounceLoader={<>Hello} - /> -
-
+
+ setItem(i)} + placeholder="Select a ingredient" + clearInputOnSelect + suppressReselect={false} + initialValue={item ? item.label : ''} + // debounceTime={1000} + // debounceLoader={<>Hello} + />
- ); +
+
+ ); } export default App; diff --git a/testing/demo-app/src/DataListInput.jsx b/testing/demo-app/src/DataListInput.jsx index 84bed8e..b9c3be5 100644 --- a/testing/demo-app/src/DataListInput.jsx +++ b/testing/demo-app/src/DataListInput.jsx @@ -1,434 +1,386 @@ -import React from 'react'; +import React, { + useState, useRef, useEffect, useCallback, useMemo, +} from 'react'; import PropTypes from 'prop-types'; import './DataListInput.css'; -const windowExists = () => typeof window !== 'undefined'; +/** + * 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).toUpperCase() === currentInput.toUpperCase(); -class DataListInput extends React.Component { - constructor( props ) { - super( props ); +/** + * 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.toUpperCase().indexOf(currentInput.toUpperCase()); - const { initialValue } = this.props; +/** + * index of item in items + * @param {*} item + * @param {*} items + */ +const indexOfItem = (item, items) => items + .indexOf(items.find(i => i.key === item.key)); - 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, - }; +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 */ - this.inputHappenedTimeout = undefined; + /* to manage debouncing of matching, typing input into the input field */ + const inputHappenedTimeout = useRef(); + const menu = useRef(); + const inputField = useRef(); + + const onClickCloseMenu = useCallback((event) => { + if (!menu.current) return; + // if rerender, items inside might change, allow one click without further checking + if (interactionHappened) { + setInteractionHappened(false); + return; + } + // 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(); + } + }, [interactionHappened, onDropdownClose]); + + useEffect(() => { + window.addEventListener('click', onClickCloseMenu, false); + return () => { + window.removeEventListener('click', onClickCloseMenu); + }; + }, []); + + 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]); + + /** + * matching process to find matching entries in items array + * @returns {Array} + */ + const computeMatchingItems = useCallback(() => items.filter((item) => { + if (typeof (match) === typeof (Function)) { return match(currentInput, item); } + return labelMatch(currentInput, item); + }), [items, match, currentInput]); + + /** + * 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; } - componentDidMount = () => { - if ( windowExists() ) { - window.addEventListener( 'click', this.onClickCloseMenu, false ); + // 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 = () => { + const updatedMatchingItems = computeMatchingItems(); + 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(); + } else { + if (visible) { + setVisible(false); + onDropdownClose(); } - } - - 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} - */ - 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} - */ - 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 - */ - 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 ); - } - - // 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 { - this.inputHappenedTimeout = setTimeout( updateMatchingItems, debounceTime ); - } - } - - /** - * 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 ); + setMatchingItems(displayableItems); + setFocusIndex(-1); + setIsMatchingDebounced(false); + } }; - onClickInput = () => { - const { visible } = this.state; - let { currentInput } = this.state; - const { requiredInputLength, initialValue } = this.props; + if (debounceTime <= 0) { + updateMatchingItems(); + } else { + inputHappenedTimeout.current = setTimeout(updateMatchingItems, debounceTime); + } + }, [requiredInputLength, debounceTime, computeMatchingItems, + dropDownLength, lastValidItem, clearInputOnSelect, + onDropdownOpen, onDropdownClose, visible]); - // 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 = ''; - } + /** + * 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 reachedRequiredLength = currentInput.length >= requiredInputLength; - if ( reachedRequiredLength && !visible ) { - this.debouncedMatchingUpdateStep( currentInput ); - } + 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 = ''; } - /** - * 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 ); - } - } - }; + const reachedRequiredLength = value.length >= requiredInputLength; + if (reachedRequiredLength && !visible) { + debouncedMatchingUpdateStep(value); + } + }, [visible, currentInput, requiredInputLength, initialValue, debouncedMatchingUpdateStep]); - /** - * 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 ); - }; + /** + * 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; - /** - * 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 ); - }; + setCurrentInput(clearInputOnSelect ? '' : selectedItem.label); + setVisible(false); + setFocusIndex(-1); + setInteractionHappened(true); + onDropdownClose(); - renderMatchingLabel = ( currentInput, item, indexOfMatch ) => ( - - {item.label.substr( 0, indexOfMatch ) } - - { item.label.substr( indexOfMatch, currentInput.length ) } - - { item.label.substr( indexOfMatch + currentInput.length, item.label.length ) } - + 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); + } + } + }, [visible, focusIndex, matchingItems, onHandleSelect]); + + const renderItemLabel = useCallback((item) => { + const index = indexOfMatch(currentInput, item); + const inputLength = currentInput.length; + console.log('renderLabelAgain'); + return ( + <> + { 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 } + ); + }, [currentInput]); - 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 ); - } + const renderItems = useCallback(() => ( +
+ {items.map((item, i) => { + const isActive = focusIndex === i; + const itemActiveClasses = isActive + ? `datalist-active-item ${activeItemClassName || 'datalist-active-item-default'}` : ''; + const itemClasses = `${itemClassName} ${itemActiveClasses}`; return ( -
- { this.renderInputField( placeholder, currentInput, inputClassName ) } - { renderedResults } -
+
onHandleSelect(item)} + className={itemClasses} + key={item.key} + tabIndex={0} + role="button" + onKeyUp={event => event.preventDefault()} + > + {renderItemLabel(item)} +
); + })} +
+ ), [dropdownClassName, items, 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 = { - items: PropTypes.arrayOf( - PropTypes.shape( { - label: PropTypes.string.isRequired, - key: PropTypes.oneOfType( [ - PropTypes.string, - PropTypes.number, - ] ).isRequired, - } ), - ).isRequired, - placeholder: PropTypes.string, - onSelect: PropTypes.func.isRequired, - onDropdownOpen: PropTypes.func, - onDropdownClose: PropTypes.func, - match: PropTypes.func, - inputClassName: PropTypes.string, - dropdownClassName: PropTypes.string, - itemClassName: PropTypes.string, - activeItemClassName: PropTypes.string, - requiredInputLength: PropTypes.number, - clearInputOnSelect: PropTypes.bool, - suppressReselect: PropTypes.bool, - dropDownLength: PropTypes.number, - initialValue: PropTypes.string, - debounceTime: PropTypes.number, - debounceLoader: PropTypes.node, - onInput: PropTypes.func, + items: PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + key: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number, + ]).isRequired, + }), + ).isRequired, + placeholder: PropTypes.string, + onSelect: PropTypes.func.isRequired, + onDropdownOpen: PropTypes.func, + onDropdownClose: PropTypes.func, + match: PropTypes.func, + inputClassName: PropTypes.string, + dropdownClassName: PropTypes.string, + itemClassName: PropTypes.string, + activeItemClassName: PropTypes.string, + requiredInputLength: PropTypes.number, + clearInputOnSelect: PropTypes.bool, + suppressReselect: PropTypes.bool, + dropDownLength: PropTypes.number, + initialValue: PropTypes.string, + debounceTime: PropTypes.number, + debounceLoader: PropTypes.node, + onInput: PropTypes.func, }; DataListInput.defaultProps = { - placeholder: '', - match: undefined, - inputClassName: '', - dropdownClassName: '', - itemClassName: '', - activeItemClassName: '', - requiredInputLength: 0, - clearInputOnSelect: false, - suppressReselect: true, - dropDownLength: Infinity, - initialValue: '', - debounceTime: 0, - debounceLoader: undefined, - onDropdownOpen: () => {}, - onDropdownClose: () => {}, - onInput: () => {}, + placeholder: '', + match: undefined, + inputClassName: '', + dropdownClassName: '', + itemClassName: '', + activeItemClassName: '', + requiredInputLength: 0, + clearInputOnSelect: false, + suppressReselect: true, + dropDownLength: Infinity, + initialValue: '', + debounceTime: 0, + debounceLoader: undefined, + onDropdownOpen: () => {}, + onDropdownClose: () => {}, + onInput: () => {}, }; export default DataListInput; diff --git a/testing/demo-app/src/index.js b/testing/demo-app/src/index.js index 75ffcef..395b749 100644 --- a/testing/demo-app/src/index.js +++ b/testing/demo-app/src/index.js @@ -3,4 +3,4 @@ import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; -ReactDOM.render( , document.getElementById( 'root' ) ); +ReactDOM.render(, document.getElementById('root'));