reverting back to class since it breaks

This commit is contained in:
andrelandgraf 2020-04-28 09:52:56 +02:00
parent 897d8a8e42
commit 43afc31062
2 changed files with 380 additions and 326 deletions

View File

@ -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",

View File

@ -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 ) => (
<React.Fragment>
{ index >= 0 && inputLength
// renders label with matching search string marked
? (
<React.Fragment>
{item.label.substr(0, index) }
<strong>
{ item.label.substr(index, inputLength) }
</strong>
{ item.label.substr(index + inputLength, item.label.length) }
</React.Fragment>
)
: item.label }
{item.label.substr( 0, indexOfMatch ) }
<strong>
{ item.label.substr( indexOfMatch, currentInput.length ) }
</strong>
{ item.label.substr( indexOfMatch + currentInput.length, item.label.length ) }
</React.Fragment>
);
}, [currentInput]);
const renderItems = useCallback(() => (
<div ref={menu} className={`datalist-items ${dropdownClassName || 'default-datalist-items'}`}>
{matchingItems.map((item, i) => {
const isActive = focusIndex === i;
const itemActiveClasses = isActive
? `datalist-active-item ${activeItemClassName || 'datalist-active-item-default'}` : '';
const itemClasses = `${itemClassName} ${itemActiveClasses}`;
return (
<div
onClick={() => onHandleSelect(item)}
className={itemClasses}
key={item.key}
tabIndex={0}
role="button"
onKeyUp={event => event.preventDefault()}
>
{renderItemLabel(item)}
</div>
);
})}
</div>
), [dropdownClassName, matchingItems, focusIndex,
activeItemClassName, itemClassName, onHandleSelect, renderItemLabel]);
const renderLoader = useCallback(() => (
<div ref={menu} className={`datalist-items ${dropdownClassName || 'default-datalist-items'}`}>
<div className={itemClassName}>{debounceLoader || 'loading...'}</div>
</div>
), [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 (
<div className="datalist-input">
<input
ref={inputField}
onChange={onHandleInput}
onClick={onClickInput}
onKeyDown={onHandleKeydown}
type="text"
className={`autocomplete-input ${inputClassName}`}
placeholder={placeholder}
value={currentInput}
/>
{ dropDown }
</div>
);
};
DataListInput.propTypes = {
renderItemLabel = ( currentInput, item, indexOfMatch ) => (
<React.Fragment>
{ indexOfMatch >= 0 && currentInput.length
? this.renderMatchingLabel( currentInput, item, indexOfMatch )
: item.label }
</React.Fragment>
)
renderItems = (
currentInput, items, focusIndex, activeItemClassName, itemClassName, dropdownClassName,
) => (
<div className={`datalist-items ${ dropdownClassName || 'default-datalist-items' }`}>
{items.map( ( item, i ) => {
const isActive = focusIndex === i;
const itemActiveClasses = isActive
? `datalist-active-item ${ activeItemClassName || 'datalist-active-item-default' }` : '';
const itemClasses = `${ itemClassName } ${ itemActiveClasses }`;
return (
<div
onClick={this.onClickItem}
className={itemClasses}
key={item.key}
tabIndex={0}
role="button"
onKeyUp={event => event.preventDefault()}
>
{this.renderItemLabel(
currentInput, item, this.indexOfMatch( currentInput, item ),
)}
<input type="hidden" value={item.key} readOnly />
</div>
);
} )}
</div>
);
renderLoader = ( debounceLoader, dropdownClassName, itemClassName ) => (
<div className={`datalist-items ${ dropdownClassName || 'default-datalist-items' }`}>
<div className={itemClassName}>{debounceLoader || 'loading...'}</div>
</div>
)
renderInputField = ( placeholder, currentInput, inputClassName ) => (
<input
onChange={this.onHandleInput}
onClick={this.onClickInput}
onKeyDown={this.onHandleKeydown}
type="text"
className={`autocomplete-input ${ inputClassName }`}
placeholder={placeholder}
value={currentInput}
/>
)
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 (
<div className="datalist-input">
{ this.renderInputField( placeholder, currentInput, inputClassName ) }
{ renderedResults }
</div>
);
}
}
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;
};
export default DataListInput;