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"
|
"react"
|
||||||
],
|
],
|
||||||
"rules": {
|
"rules": {
|
||||||
"indent": ["error", 4],
|
"react/jsx-filename-extension": [1, { "extensions": [".js", ".jsx"] }]
|
||||||
"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" ] } ]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable no-unused-vars */
|
||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import csvFile from './data.csv';
|
import csvFile from './data.csv';
|
||||||
|
|
||||||
@ -6,96 +7,96 @@ import DataListInput from './DataListInput';
|
|||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
const data = [
|
const data = [
|
||||||
{
|
{
|
||||||
key: '0',
|
key: '0',
|
||||||
label: 'Apple',
|
label: 'Apple',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '1',
|
key: '1',
|
||||||
label: 'Mango',
|
label: 'Mango',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: '2',
|
key: '2',
|
||||||
label: 'Potatoe',
|
label: 'Potatoe',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
async function getAndParseData( filename ) {
|
async function getAndParseData(filename) {
|
||||||
const response = await fetch( filename );
|
const response = await fetch(filename);
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder( 'utf-8' );
|
const decoder = new TextDecoder('utf-8');
|
||||||
const result = await reader.read();
|
const result = await reader.read();
|
||||||
const allText = decoder.decode( result.value );
|
const allText = decoder.decode(result.value);
|
||||||
|
|
||||||
const allTextLines = allText.split( /\r\n|\n/ );
|
const allTextLines = allText.split(/\r\n|\n/);
|
||||||
const headers = allTextLines[ 0 ].split( ',' );
|
const headers = allTextLines[0].split(',');
|
||||||
const lines = [];
|
const lines = [];
|
||||||
|
|
||||||
for ( let i = 1; i < allTextLines.length; i += 1 ) {
|
for (let i = 1; i < allTextLines.length; i += 1) {
|
||||||
const set = allTextLines[ i ].split( ',' );
|
const set = allTextLines[i].split(',');
|
||||||
if ( set.length === headers.length ) {
|
if (set.length === headers.length) {
|
||||||
const tarr = {};
|
const tarr = {};
|
||||||
for ( let j = 0; j < headers.length; j += 1 ) {
|
for (let j = 0; j < headers.length; j += 1) {
|
||||||
tarr[ headers[ j ] ] = set[ j ];
|
tarr[headers[j]] = set[j];
|
||||||
}
|
}
|
||||||
lines.push( tarr );
|
lines.push(tarr);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return lines;
|
}
|
||||||
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
// eslint-disable-next-line no-unused-vars
|
||||||
function annoyinglySlowMatchingAlg( currentInput, item ) {
|
function annoyinglySlowMatchingAlg(currentInput, item) {
|
||||||
for ( let i = 0; i < 100000; i += 1 ) {
|
for (let i = 0; i < 100000; i += 1) {
|
||||||
i += 1;
|
i += 1;
|
||||||
i -= 1;
|
i -= 1;
|
||||||
// eslint-disable-next-line no-unused-expressions
|
// eslint-disable-next-line no-unused-expressions
|
||||||
( currentInput.length + item.label.length ) % 2;
|
(currentInput.length + item.label.length) % 2;
|
||||||
}
|
}
|
||||||
return item.label.substr( 0, currentInput.length ).toUpperCase() === currentInput.toUpperCase();
|
return item.label.substr(0, currentInput.length).toUpperCase() === currentInput.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [ item, setItem ] = useState();
|
const [item, setItem] = useState();
|
||||||
const [ items, setItems ] = useState( [] );
|
const [items, setItems] = useState(data);
|
||||||
|
|
||||||
useEffect( () => {
|
// useEffect(() => {
|
||||||
getAndParseData( csvFile ).then( obj => setItems( obj
|
// getAndParseData(csvFile).then(obj => setItems(obj
|
||||||
.concat( obj )
|
// .concat(obj)
|
||||||
.map( ( row, i ) => (
|
// .map((row, i) => (
|
||||||
{
|
// {
|
||||||
...row,
|
// ...row,
|
||||||
label: row.vorname,
|
// label: row.vorname,
|
||||||
key: i,
|
// key: i,
|
||||||
}
|
// }
|
||||||
) ) ) );
|
// ))));
|
||||||
}, [] );
|
// }, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="App">
|
<div className="App">
|
||||||
<div className="content">
|
<div className="content">
|
||||||
{
|
{
|
||||||
item && (
|
item && (
|
||||||
<div>
|
<div>
|
||||||
{ `Current Item: ${ item.label }` }
|
{ `Current Item: ${item.label}` }
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
<div className="wrapper">
|
<div className="wrapper">
|
||||||
<DataListInput
|
<DataListInput
|
||||||
items={items}
|
items={items}
|
||||||
onSelect={i => setItem( i )}
|
onSelect={i => setItem(i)}
|
||||||
placeholder="Select a ingredient"
|
placeholder="Select a ingredient"
|
||||||
clearInputOnSelect={false}
|
clearInputOnSelect
|
||||||
suppressReselect={false}
|
suppressReselect={false}
|
||||||
initialValue={item ? item.label : ''}
|
initialValue={item ? item.label : ''}
|
||||||
debounceTime={1000}
|
// debounceTime={1000}
|
||||||
debounceLoader={<>Hello</>}
|
// debounceLoader={<>Hello</>}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App;
|
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 PropTypes from 'prop-types';
|
||||||
|
|
||||||
import './DataListInput.css';
|
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 ) {
|
* function for getting the index of the currentValue inside a value of the values array
|
||||||
super( props );
|
* @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 = {
|
const DataListInput = ({
|
||||||
/* last valid item that was selected from the drop down menu */
|
activeItemClassName,
|
||||||
lastValidItem: undefined,
|
clearInputOnSelect,
|
||||||
/* current input text */
|
debounceLoader,
|
||||||
currentInput: initialValue,
|
debounceTime,
|
||||||
/* current set of matching items */
|
dropdownClassName,
|
||||||
matchingItems: [],
|
dropDownLength,
|
||||||
/* visibility property of the drop down menu */
|
initialValue,
|
||||||
visible: false,
|
inputClassName,
|
||||||
/* index of the currently focused item in the drop down menu */
|
itemClassName,
|
||||||
focusIndex: 0,
|
match,
|
||||||
/* cleaner click events, click interaction within dropdown menu */
|
onDropdownClose,
|
||||||
interactionHappened: false,
|
onDropdownOpen,
|
||||||
/* show loader if still matching in debounced mode */
|
onInput,
|
||||||
isMatchingDebounced: false,
|
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 */
|
/* to manage debouncing of matching, typing input into the input field */
|
||||||
this.inputHappenedTimeout = undefined;
|
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 = () => {
|
// set nextInput into input field and show loading if debounced mode is on
|
||||||
if ( windowExists() ) {
|
const reachedRequiredLength = nextInput.length >= requiredInputLength;
|
||||||
window.addEventListener( 'click', this.onClickCloseMenu, false );
|
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();
|
||||||
}
|
}
|
||||||
}
|
setMatchingItems(displayableItems);
|
||||||
|
setFocusIndex(-1);
|
||||||
componentDidUpdate = () => {
|
setIsMatchingDebounced(false);
|
||||||
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 );
|
|
||||||
};
|
};
|
||||||
|
|
||||||
onClickInput = () => {
|
if (debounceTime <= 0) {
|
||||||
const { visible } = this.state;
|
updateMatchingItems();
|
||||||
let { currentInput } = this.state;
|
} else {
|
||||||
const { requiredInputLength, initialValue } = this.props;
|
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
|
* gets called when someone starts to write in the input field
|
||||||
if ( initialValue && currentInput === initialValue ) {
|
* @param value
|
||||||
this.setState( { currentInput: '' } );
|
*/
|
||||||
currentInput = '';
|
const onHandleInput = useCallback((event) => {
|
||||||
}
|
const { value } = event.target;
|
||||||
|
debouncedMatchingUpdateStep(value);
|
||||||
|
onInput(value);
|
||||||
|
}, [debouncedMatchingUpdateStep, onInput]);
|
||||||
|
|
||||||
const reachedRequiredLength = currentInput.length >= requiredInputLength;
|
const onClickInput = useCallback(() => {
|
||||||
if ( reachedRequiredLength && !visible ) {
|
let value = currentInput;
|
||||||
this.debouncedMatchingUpdateStep( 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;
|
||||||
* handle key events
|
if (reachedRequiredLength && !visible) {
|
||||||
* @param event
|
debouncedMatchingUpdateStep(value);
|
||||||
*/
|
}
|
||||||
onHandleKeydown = ( event ) => {
|
}, [visible, currentInput, requiredInputLength, initialValue, debouncedMatchingUpdateStep]);
|
||||||
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
|
* handleSelect is called onClickItem and onEnter upon an option of the drop down menu
|
||||||
* @param event
|
* does nothing if the key has not changed since the last onSelect event
|
||||||
*/
|
* @param selectedItem
|
||||||
onClickItem = ( event ) => {
|
*/
|
||||||
const { matchingItems } = this.state;
|
const onHandleSelect = useCallback((selectedItem) => {
|
||||||
// update the input value and close the dropdown again
|
// block select call until last matching went through
|
||||||
const elements = event.currentTarget.children;
|
if (isMatchingDebounced) return;
|
||||||
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 );
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
setCurrentInput(clearInputOnSelect ? '' : selectedItem.label);
|
||||||
* onSelect is called onClickItem and onEnter upon an option of the drop down menu
|
setVisible(false);
|
||||||
* does nothing if the key has not changed since the last onSelect event
|
setFocusIndex(-1);
|
||||||
* @param selectedItem
|
setInteractionHappened(true);
|
||||||
*/
|
onDropdownClose();
|
||||||
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 ) => (
|
if (suppressReselect && lastValidItem && selectedItem.key === lastValidItem.key) {
|
||||||
<React.Fragment>
|
// do not trigger the callback function
|
||||||
{item.label.substr( 0, indexOfMatch ) }
|
// but still change state to fit new selection
|
||||||
<strong>
|
return;
|
||||||
{ item.label.substr( indexOfMatch, currentInput.length ) }
|
}
|
||||||
</strong>
|
// change state to fit new selection
|
||||||
{ item.label.substr( indexOfMatch + currentInput.length, item.label.length ) }
|
setLastValidItem(selectedItem);
|
||||||
</React.Fragment>
|
// 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 ) => (
|
const renderItems = useCallback(() => (
|
||||||
<React.Fragment>
|
<div ref={menu} className={`datalist-items ${dropdownClassName || 'default-datalist-items'}`}>
|
||||||
{ indexOfMatch >= 0 && currentInput.length
|
{items.map((item, i) => {
|
||||||
? this.renderMatchingLabel( currentInput, item, indexOfMatch )
|
const isActive = focusIndex === i;
|
||||||
: item.label }
|
const itemActiveClasses = isActive
|
||||||
</React.Fragment>
|
? `datalist-active-item ${activeItemClassName || 'datalist-active-item-default'}` : '';
|
||||||
)
|
const itemClasses = `${itemClassName} ${itemActiveClasses}`;
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<div className="datalist-input">
|
<div
|
||||||
{ this.renderInputField( placeholder, currentInput, inputClassName ) }
|
onClick={() => onHandleSelect(item)}
|
||||||
{ renderedResults }
|
className={itemClasses}
|
||||||
</div>
|
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 = {
|
DataListInput.propTypes = {
|
||||||
items: PropTypes.arrayOf(
|
items: PropTypes.arrayOf(
|
||||||
PropTypes.shape( {
|
PropTypes.shape({
|
||||||
label: PropTypes.string.isRequired,
|
label: PropTypes.string.isRequired,
|
||||||
key: PropTypes.oneOfType( [
|
key: PropTypes.oneOfType([
|
||||||
PropTypes.string,
|
PropTypes.string,
|
||||||
PropTypes.number,
|
PropTypes.number,
|
||||||
] ).isRequired,
|
]).isRequired,
|
||||||
} ),
|
}),
|
||||||
).isRequired,
|
).isRequired,
|
||||||
placeholder: PropTypes.string,
|
placeholder: PropTypes.string,
|
||||||
onSelect: PropTypes.func.isRequired,
|
onSelect: PropTypes.func.isRequired,
|
||||||
onDropdownOpen: PropTypes.func,
|
onDropdownOpen: PropTypes.func,
|
||||||
onDropdownClose: PropTypes.func,
|
onDropdownClose: PropTypes.func,
|
||||||
match: PropTypes.func,
|
match: PropTypes.func,
|
||||||
inputClassName: PropTypes.string,
|
inputClassName: PropTypes.string,
|
||||||
dropdownClassName: PropTypes.string,
|
dropdownClassName: PropTypes.string,
|
||||||
itemClassName: PropTypes.string,
|
itemClassName: PropTypes.string,
|
||||||
activeItemClassName: PropTypes.string,
|
activeItemClassName: PropTypes.string,
|
||||||
requiredInputLength: PropTypes.number,
|
requiredInputLength: PropTypes.number,
|
||||||
clearInputOnSelect: PropTypes.bool,
|
clearInputOnSelect: PropTypes.bool,
|
||||||
suppressReselect: PropTypes.bool,
|
suppressReselect: PropTypes.bool,
|
||||||
dropDownLength: PropTypes.number,
|
dropDownLength: PropTypes.number,
|
||||||
initialValue: PropTypes.string,
|
initialValue: PropTypes.string,
|
||||||
debounceTime: PropTypes.number,
|
debounceTime: PropTypes.number,
|
||||||
debounceLoader: PropTypes.node,
|
debounceLoader: PropTypes.node,
|
||||||
onInput: PropTypes.func,
|
onInput: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
DataListInput.defaultProps = {
|
DataListInput.defaultProps = {
|
||||||
placeholder: '',
|
placeholder: '',
|
||||||
match: undefined,
|
match: undefined,
|
||||||
inputClassName: '',
|
inputClassName: '',
|
||||||
dropdownClassName: '',
|
dropdownClassName: '',
|
||||||
itemClassName: '',
|
itemClassName: '',
|
||||||
activeItemClassName: '',
|
activeItemClassName: '',
|
||||||
requiredInputLength: 0,
|
requiredInputLength: 0,
|
||||||
clearInputOnSelect: false,
|
clearInputOnSelect: false,
|
||||||
suppressReselect: true,
|
suppressReselect: true,
|
||||||
dropDownLength: Infinity,
|
dropDownLength: Infinity,
|
||||||
initialValue: '',
|
initialValue: '',
|
||||||
debounceTime: 0,
|
debounceTime: 0,
|
||||||
debounceLoader: undefined,
|
debounceLoader: undefined,
|
||||||
onDropdownOpen: () => {},
|
onDropdownOpen: () => {},
|
||||||
onDropdownClose: () => {},
|
onDropdownClose: () => {},
|
||||||
onInput: () => {},
|
onInput: () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DataListInput;
|
export default DataListInput;
|
||||||
|
@ -3,4 +3,4 @@ import ReactDOM from 'react-dom';
|
|||||||
import './index.css';
|
import './index.css';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
ReactDOM.render( <App />, document.getElementById( 'root' ) );
|
ReactDOM.render(<App />, document.getElementById('root'));
|
||||||
|
Loading…
Reference in New Issue
Block a user