mirror of
https://github.com/danog/react-datalist-input.git
synced 2024-12-02 09:27:53 +01:00
refactoring to functional component, and added onIput to type def
This commit is contained in:
parent
e0a4cade03
commit
48bf055334
@ -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"] }]
|
||||
}
|
||||
}
|
@ -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 (
|
||||
<div className="App">
|
||||
<div className="content">
|
||||
{
|
||||
return (
|
||||
<div className="App">
|
||||
<div className="content">
|
||||
{
|
||||
item && (
|
||||
<div>
|
||||
{ `Current Item: ${ item.label }` }
|
||||
</div>
|
||||
<div>
|
||||
{ `Current Item: ${item.label}` }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className="wrapper">
|
||||
<DataListInput
|
||||
items={items}
|
||||
onSelect={i => setItem( i )}
|
||||
placeholder="Select a ingredient"
|
||||
clearInputOnSelect={false}
|
||||
suppressReselect={false}
|
||||
initialValue={item ? item.label : ''}
|
||||
debounceTime={1000}
|
||||
debounceLoader={<>Hello</>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="wrapper">
|
||||
<DataListInput
|
||||
items={items}
|
||||
onSelect={i => setItem(i)}
|
||||
placeholder="Select a ingredient"
|
||||
clearInputOnSelect
|
||||
suppressReselect={false}
|
||||
initialValue={item ? item.label : ''}
|
||||
// debounceTime={1000}
|
||||
// debounceLoader={<>Hello</>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
@ -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 ) => (
|
||||
<React.Fragment>
|
||||
{item.label.substr( 0, indexOfMatch ) }
|
||||
<strong>
|
||||
{ item.label.substr( indexOfMatch, currentInput.length ) }
|
||||
</strong>
|
||||
{ item.label.substr( indexOfMatch + currentInput.length, item.label.length ) }
|
||||
</React.Fragment>
|
||||
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) }
|
||||
<strong>
|
||||
{ item.label.substr(index, inputLength) }
|
||||
</strong>
|
||||
{ item.label.substr(index + inputLength, item.label.length) }
|
||||
</>
|
||||
)
|
||||
: item.label }
|
||||
</>
|
||||
);
|
||||
}, [currentInput]);
|
||||
|
||||
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 );
|
||||
}
|
||||
const renderItems = useCallback(() => (
|
||||
<div ref={menu} 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 className="datalist-input">
|
||||
{ this.renderInputField( placeholder, currentInput, inputClassName ) }
|
||||
{ renderedResults }
|
||||
</div>
|
||||
<div
|
||||
onClick={() => onHandleSelect(item)}
|
||||
className={itemClasses}
|
||||
key={item.key}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyUp={event => event.preventDefault()}
|
||||
>
|
||||
{renderItemLabel(item)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
), [dropdownClassName, items, 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 = {
|
||||
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;
|
||||
|
@ -3,4 +3,4 @@ import ReactDOM from 'react-dom';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
ReactDOM.render( <App />, document.getElementById( 'root' ) );
|
||||
ReactDOM.render(<App />, document.getElementById('root'));
|
||||
|
Loading…
Reference in New Issue
Block a user