@talend/react-components
Version:
Set of react components.
465 lines (448 loc) • 14.7 kB
JavaScript
/* eslint-disable react/jsx-no-bind */
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import PropTypes from 'prop-types';
import I18N_DOMAIN_COMPONENTS from '../constants';
import FocusManager from '../FocusManager';
import Icon from '../Icon';
import Typeahead from '../Typeahead';
import theme from './Datalist.module.scss';
import { get, omit } from "lodash";
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
export function escapeRegexCharacters(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const PROPS_TO_OMIT = ['restricted', 'titleMap', 'value'];
const DISPLAY = {
ALL: 'all',
FILTER: 'filter',
NONE: 'none'
};
function isValuePresentInSuggestions(titleMap, filterValue, multiSection) {
return multiSection ? titleMap.find(group => group.suggestions.find(item => filterValue.toLowerCase() === item.name.toLowerCase())) : titleMap.find(itemValue => filterValue.toLowerCase() === itemValue.name.toLowerCase());
}
function buildSuggestions({
displayMode,
titleMap,
filterValue,
multiSection,
allowAddNewElements,
allowAddNewElementsSuffix
}) {
if (displayMode === DISPLAY.NONE) {
return undefined;
}
if (displayMode === DISPLAY.ALL && filterValue) {
const result = [...titleMap];
if (allowAddNewElements && !isValuePresentInSuggestions(titleMap, filterValue, multiSection)) {
result.unshift({
title: `${filterValue} ${allowAddNewElementsSuffix}`,
name: filterValue,
value: filterValue
});
}
return result;
}
if (displayMode === DISPLAY.ALL || !filterValue) {
return titleMap;
}
// building multiSection items or single section items
const escapedValue = escapeRegexCharacters(filterValue.trim());
const regex = new RegExp(escapedValue, 'i');
const result = multiSection ? titleMap.map(group => ({
...group,
suggestions: filterValue ? group.suggestions.filter(item => regex.test(item.name)) : group.suggestions
})).filter(group => group.suggestions.length > 0) : titleMap.filter(itemValue => regex.test(itemValue.name));
if (allowAddNewElements && !isValuePresentInSuggestions(titleMap, filterValue, multiSection)) {
result.unshift({
title: `${filterValue} ${allowAddNewElementsSuffix}`,
name: filterValue,
value: filterValue
});
}
return result;
}
function findEntry(titleMap, attributeName, attributeValue = '') {
if (!titleMap) {
return null;
}
for (let index = 0; index < titleMap.length; index += 1) {
const entry = titleMap[index];
if (entry.suggestions) {
// entry is a category, it has suggestions that are real entries {name, value}
const categorySuggestion = entry.suggestions.find(item => !item.disabled && item[attributeName].toLowerCase() === attributeValue.toLowerCase());
if (categorySuggestion) {
return categorySuggestion;
}
} else if (!entry.disabled && String(entry[attributeName]).toLowerCase() === String(attributeValue).toLowerCase()) {
// entry is {name, value}
return entry;
}
}
return null;
}
function getEntryFromName(titleMap, name, restricted) {
if (name === '') {
return {
name,
value: ''
};
}
const entry = findEntry(titleMap, 'name', name);
if (entry) {
return entry;
}
return restricted ? undefined : {
name,
value: name
};
}
function getEntryFromValue(titleMap, value, restricted) {
const entry = findEntry(titleMap, 'value', value);
if (entry) {
return entry;
}
return restricted ? undefined : {
name: value,
value
};
}
function getEntry(titleMap, nameOrValue, restricted) {
const entry = findEntry(titleMap, 'name', nameOrValue) || findEntry(titleMap, 'value', nameOrValue);
if (entry) {
return entry;
}
return restricted ? undefined : {
name: nameOrValue,
value: nameOrValue
};
}
function Datalist(props) {
var _props$allowAddNewEle;
// Current persisted value
// It's the object value key { name: "display value", value: "technical value" }
const [{
name,
value
}, setEntry] = useState({});
const {
t
} = useTranslation(I18N_DOMAIN_COMPONENTS);
// suggestions: filter value, display flag, current hover selection
const [filterValue, setFilterValue] = useState('');
const [displaySuggestion, setDisplaySuggestion] = useState(DISPLAY.NONE);
const [selection, setSelection] = useState({
focusedItemIndex: undefined,
focusedSectionIndex: undefined
});
// suggestions computation
// we don't memo bacause it changes at EVERY modification
const suggestions = buildSuggestions({
displayMode: props.readOnly || props.disabled ? DISPLAY.NONE : displaySuggestion,
titleMap: props.titleMap,
filterValue,
multiSection: props.multiSection,
allowAddNewElements: props.allowAddNewElements,
allowAddNewElementsSuffix: (_props$allowAddNewEle = props.allowAddNewElementsSuffix) !== null && _props$allowAddNewEle !== void 0 ? _props$allowAddNewEle : t('NEW_WITH_PARENTHESIS', '(new)')
});
// in uncontrolled mode, props.value acts as an initial value, then Datalist handles state, props.value never changes.
// in controlled mode, props.value has to be reflected every time it changes
useEffect(() => {
if (props.value === undefined || props.value === null) {
return;
}
const entry = getEntryFromValue(props.titleMap, props.value);
if (!entry) {
return;
}
if (entry.value !== value || entry.name !== name) {
setEntry(entry);
}
// Update the input value only if user did not change it
if (!name && !filterValue || name === filterValue) {
setFilterValue(entry.name);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.value, props.titleMap]);
// Suggestion display syntaxic sugar
const resetSelection = () => setSelection({
focusedItemIndex: undefined,
focusedSectionIndex: undefined
});
const showFilteredSuggestions = () => setDisplaySuggestion(DISPLAY.FILTER);
const showAllSuggestions = () => setDisplaySuggestion(DISPLAY.ALL);
const hideSuggestions = () => {
setDisplaySuggestion(DISPLAY.NONE);
resetSelection();
};
const resetFilter = () => {
const entry = getEntryFromValue(props.titleMap, value);
if (entry) {
setFilterValue(entry.name);
}
};
/**
* Set a value.
* This new value can be persisted, or not. If not, it enables the ESC key to reset the value
* @param event The change event
* @param newValue The new value
* @param persist Value will be persisted if true
*/
function updateValue(event, entry, persist) {
// update the suggestion filter
const newFilter = entry.name;
setFilterValue(newFilter);
if (persist) {
setEntry(entry);
props.onChange(event, {
value: entry.value
});
} else if (props.onLiveChange) {
props.onLiveChange(event, entry.value);
}
}
/**
* Update the focused item and section given the current value
* Managing the two cases (multi section and single section)
*/
function setSelectionToCurrentValue() {
if (props.multiSection) {
const groups = props.titleMap;
for (let sectionIndex = 0; sectionIndex < groups.length; sectionIndex += 1) {
const focusedIndex = groups[sectionIndex].suggestions.findIndex(item => item.name === value);
if (focusedIndex > -1) {
setSelection({
focusedItemIndex: focusedIndex,
focusedSectionIndex: sectionIndex
});
break;
}
}
} else {
const index = props.titleMap.findIndex(item => item.name === value);
setSelection({
focusedItemIndex: index === -1 ? null : index
});
}
}
/**
* Persist the current filter value as value
* @param {*} event
*/
function persistValue(event) {
hideSuggestions();
const selectedEntry = getEntryFromValue(props.titleMap, value, props.restricted);
// If the filterValue is different from the selected entry
if (!selectedEntry || selectedEntry.name !== filterValue) {
const entry = getEntryFromName(props.titleMap, filterValue, props.restricted);
if (entry && entry.value !== value) {
updateValue(event, entry, true);
} else {
resetFilter();
}
}
}
/**
* On blur: persist the value and remove the suggestions
* @param event The blur event
*/
const onBlur = function onBlur(event) {
if (props.onBlur) {
props.onBlur();
}
persistValue(event);
};
/**
* Update value (non persistent) on input value change and update the suggestions
* @param event the change event
* @param payload
*/
function onFilterChange(event, payload) {
const entry = getEntry(props.titleMap, payload.value);
updateValue(event, entry, false);
showFilteredSuggestions();
// resetting selection here in order to reinit the section + item indexes
resetSelection();
}
/**
* Display suggestions on focus
* @param event the focus event
*/
function onFocus(event) {
if (props.onFocus) {
props.onFocus(event);
}
event.target.select();
showAllSuggestions();
setSelectionToCurrentValue();
}
/**
* Display suggestions on click
* @param event the click event
*/
function onClick() {
showAllSuggestions();
setSelectionToCurrentValue();
}
/**
* Select an item in suggestions list
* @param event The select event
* @param sectionIndex The section index in suggestions list
* @param itemIndex The item index in suggestions list
*/
function onSelect(event, {
sectionIndex,
itemIndex
}) {
const newEntry = props.multiSection ? suggestions[sectionIndex].suggestions[itemIndex] : suggestions[itemIndex];
hideSuggestions();
if (newEntry.disabled || newEntry.value === value) {
event.preventDefault();
return;
}
updateValue(event, newEntry, true);
}
/**
* Hook on input keydown
* ESC: reset the value to the previous persited one
* ENTER: select a suggestion, persist the value, or submit the form
* UP/DOWN: update the active suggestion index
* @param event The keydown event
* @param highlightedItemIndex The previous focused suggestion index
* @param newHighlightedItemIndex The new focused suggestion index
* @param highlightedSectionIndex The previous focused section index
* @param newHighlightedSectionIndex The new focused section index
*/
function onKeyDown(event, params) {
const {
highlightedItemIndex,
highlightedSectionIndex,
newHighlightedItemIndex,
newHighlightedSectionIndex
} = params;
switch (event.key) {
case 'Esc':
case 'Escape':
event.preventDefault();
resetFilter();
hideSuggestions();
break;
case 'Enter':
if (!suggestions) {
break;
}
event.preventDefault();
if (Number.isInteger(highlightedItemIndex)) {
// suggestions are displayed and an item has the focus : we select it
onSelect(event, {
itemIndex: highlightedItemIndex,
sectionIndex: highlightedSectionIndex
});
} else {
// there is no focused item and the current value is not persisted, we persist it
persistValue(event);
}
break;
case 'Down':
case 'ArrowDown':
case 'Up':
case 'ArrowUp':
event.preventDefault();
if (!suggestions) {
// display all suggestions when they are not displayed
showAllSuggestions();
setSelectionToCurrentValue();
break;
}
setSelection({
focusedItemIndex: newHighlightedItemIndex,
focusedSectionIndex: newHighlightedSectionIndex
});
break;
default:
break;
}
}
/**
* Returns the selected item's icon props if there's one or undefined.
* @returns {Object|undefined}
*/
function getSelectedIcon() {
if (props.titleMap) {
if (props.multiSection) {
const multiSection = props.titleMap.find(titleMap => titleMap.suggestions.find(suggestion => suggestion.name === value));
return get(multiSection && multiSection.suggestions.find(suggestion => suggestion.name === value), 'icon');
}
const item = props.titleMap.find(titleMap => titleMap.name === value) || props.titleMap.find(titleMap => titleMap.value === value);
if (item) {
return get(item, 'icon');
}
}
return undefined;
}
const icon = getSelectedIcon();
return /*#__PURE__*/_jsxs(FocusManager, {
onFocusOut: onBlur,
className: theme['tc-datalist-item'],
children: [icon && /*#__PURE__*/_jsx(Icon, {
className: theme['tc-datalist-item-icon'],
...icon
}), /*#__PURE__*/_jsx(Typeahead, {
...omit(props, PROPS_TO_OMIT),
className: classNames('tc-datalist', props.className),
focusedItemIndex: selection.focusedItemIndex,
focusedSectionIndex: selection.focusedSectionIndex,
items: suggestions,
onChange: onFilterChange,
onFocus: onFocus,
onClick: onClick,
onKeyDown: onKeyDown,
onSelect: onSelect,
theme: {
container: classNames(theme.container, 'tc-datalist-container'),
itemsContainer: theme['items-container'],
itemsList: theme.items
},
value: filterValue,
valueId: value,
caret: true
})]
}, "focus-manager");
}
Datalist.displayName = 'Datalist component';
Datalist.defaultProps = {
value: '',
restricted: false,
multiSection: false,
titleMap: []
};
Datalist.propTypes = {
autoFocus: PropTypes.bool,
allowAddNewElements: PropTypes.bool,
allowAddNewElementsSuffix: PropTypes.string,
isLoading: PropTypes.bool,
className: PropTypes.string,
onBlur: PropTypes.func,
onChange: PropTypes.func.isRequired,
onFocus: PropTypes.func,
onClick: PropTypes.func,
onLiveChange: PropTypes.func,
disabled: PropTypes.bool,
multiSection: PropTypes.bool,
readOnly: PropTypes.bool,
restricted: PropTypes.bool,
titleMap: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.shape({
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired
}), PropTypes.shape({
title: PropTypes.string,
suggestions: PropTypes.arrayOf(PropTypes.shape({
name: PropTypes.string,
value: PropTypes.string
}))
})])),
value: PropTypes.string
};
export default Datalist;
//# sourceMappingURL=Datalist.component.js.map