src/components/Lookup/index.js
/* eslint-disable react/sort-comp */
/* eslint-disable react/no-did-update-set-state, react/no-did-mount-set-state */
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import RenderIf from '../RenderIf';
import RightElement from './rightElement';
import SelectedValue from './selectedValue';
import Options from './options';
import {
isNavigationKey,
getNormalizedOptions,
getInitialFocusedIndex,
isOptionVisible,
isMenuOpen,
} from './helpers';
import { uniqueId } from '../../libs/utils';
import { UP_KEY, DOWN_KEY, ENTER_KEY, ESCAPE_KEY } from '../../libs/constants';
import withReduxForm from '../../libs/hocs/withReduxForm';
import Label from '../Input/label';
import StyledInput from './styled/input';
import StyledContainer from './styled/container';
import StyledInputContainer from './styled/inputContainer';
import StyledSpinner from './styled/spinner';
import StyledOptionsMenu from './styled/optionsMenu';
import StyledSearchIcon from './styled/searchIcon';
import StyledTextError from '../Input/styled/errorText';
import isScrollPositionAtMenuBottom from './helpers/isScrollPositionAtMenuBottom';
import MenuArrowButton from './menuArrowButton';
import InternalOverlay from '../InternalOverlay';
import lookupPositionResolver from './helpers/lookupPositionResolver';
import { WindowScrolling } from '../../libs/scrollController';
const OPTION_HEIGHT = 48;
const visibleOptionsMap = {
small: 3,
medium: 5,
large: 8,
};
/**
* A Lookup is an autocomplete text input that will search against a database object,
* it is enhanced by a panel of suggested options.
* @category Form
*/
class Lookup extends Component {
constructor(props) {
super(props);
const normalizedOptions = getNormalizedOptions(props.options || []);
this.state = {
searchValue: '',
isOpen: false,
isFocused: false,
options: normalizedOptions,
focusedItemIndex: getInitialFocusedIndex(
normalizedOptions,
props.preferredSelectedOption,
),
showScrollUpArrow: undefined,
showScrollDownArrow: undefined,
};
this.inputId = uniqueId('lookup-input');
this.listboxId = uniqueId('lookup-listbox');
this.errorMessageId = uniqueId('error-message');
this.containerRef = React.createRef();
this.inputRef = React.createRef();
this.menuRef = React.createRef();
this.handleSearch = this.handleSearch.bind(this);
this.clearInput = this.clearInput.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleFocus = this.handleFocus.bind(this);
this.handleRemoveValue = this.handleRemoveValue.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleHover = this.handleHover.bind(this);
this.handleKeyDown = this.handleKeyDown.bind(this);
this.handleKeyUpPressed = this.handleKeyUpPressed.bind(this);
this.handleKeyDownPressed = this.handleKeyDownPressed.bind(this);
this.handleKeyEnterPressed = this.handleKeyEnterPressed.bind(this);
this.keyHandlerMap = {
[UP_KEY]: this.handleKeyUpPressed,
[DOWN_KEY]: this.handleKeyDownPressed,
[ENTER_KEY]: this.handleKeyEnterPressed,
};
this.handleScrollDownArrowHover = this.handleScrollDownArrowHover.bind(this);
this.handleScrollUpArrowHover = this.handleScrollUpArrowHover.bind(this);
this.stopArrowScoll = this.stopArrowScoll.bind(this);
this.updateScrollingArrows = this.updateScrollingArrows.bind(this);
this.handleWindowScroll = this.handleWindowScroll.bind(this);
this.handleOverlayOpened = this.handleOverlayOpened.bind(this);
this.handleClick = this.handleClick.bind(this);
this.windowScrolling = new WindowScrolling();
}
componentDidUpdate(prevProps, prevState) {
const {
options: prevOptions,
preferredSelectedOption: prevPreferredSelectedOption,
} = prevProps;
const { options, preferredSelectedOption } = this.props;
if (prevOptions !== options) {
const normalizedOptions = getNormalizedOptions(options);
this.setState({
options: normalizedOptions,
focusedItemIndex: getInitialFocusedIndex(
normalizedOptions,
preferredSelectedOption,
),
isOpen: this.isLookupOpen(),
});
}
if (prevPreferredSelectedOption !== preferredSelectedOption) {
const { options: currentOptions } = this.state;
this.setState({
focusedItemIndex: getInitialFocusedIndex(currentOptions, preferredSelectedOption),
isOpen: this.isLookupOpen(),
});
}
const { isOpen: wasOpen } = prevState;
const { isOpen } = this.state;
if (!wasOpen && isOpen && this.menuRef.current !== null) {
this.updateScrollingArrows();
}
if (!this.isLookupOpen()) this.windowScrolling.stopListening();
}
getValue() {
const { value } = this.props;
if (typeof value === 'object' && !Array.isArray(value)) {
return value;
}
return undefined;
}
getErrorMessageId() {
const { error } = this.props;
if (error) {
return this.errorMessageId;
}
return undefined;
}
getAriaActivedescendant() {
const { isFocused, focusedItemIndex } = this.state;
const { options } = this.props;
const isOpen = isMenuOpen(options, isFocused);
if (isOpen) {
return `lookup-item-${focusedItemIndex}`;
}
return undefined;
}
handleChange(value) {
const { onChange } = this.props;
setTimeout(() => this.containerRef.current.focus(), 0);
this.setState({
searchValue: '',
});
this.closeMenu();
onChange(value);
}
handleSearch(event) {
const { value } = event.target;
this.setState({
searchValue: value,
});
this.fireSearch(value);
}
handleFocus() {
const { onFocus, value } = this.props;
this.openMenu();
const eventValue = value || null;
onFocus(eventValue);
}
handleBlur() {
const { onBlur, value } = this.props;
this.closeMenu();
const eventValue = value || null;
onBlur(eventValue);
}
handleClick(event) {
const { onClick } = this.props;
this.openMenu();
return onClick(event);
}
handleRemoveValue() {
const { onChange, onSearch } = this.props;
onChange(null);
onSearch('');
setTimeout(() => this.focus(), 0);
}
fireSearch(value) {
const { onSearch, debounce } = this.props;
if (debounce && value) {
this.resetTimeout();
this.timeout = setTimeout(() => {
onSearch(value);
}, 500);
} else {
this.resetTimeout();
onSearch(value);
}
}
clearInput() {
const searchValue = '';
this.setState({
searchValue,
});
this.fireSearch(searchValue);
setTimeout(() => this.focus(), 0);
}
resetTimeout() {
if (this.timeout) {
clearTimeout(this.timeout);
}
}
openMenu() {
return this.setState({
isFocused: true,
});
}
closeMenu() {
const { options } = this.state;
const { preferredSelectedOption } = this.props;
return this.setState({
isFocused: false,
isOpen: false,
focusedItemIndex: getInitialFocusedIndex(options, preferredSelectedOption),
});
}
isLookupOpen() {
const { searchValue, isFocused } = this.state;
const { options } = this.props;
const isMenuEmpty =
isFocused && !!searchValue && Array.isArray(options) && options.length === 0;
const isOpen = isMenuOpen(options, isFocused);
return isOpen || isMenuEmpty;
}
handleHover(index) {
this.setState({
focusedItemIndex: index,
});
}
handleKeyDown(event) {
const { searchValue } = this.state;
const { keyCode } = event;
if (keyCode === ESCAPE_KEY) {
if (searchValue) {
event.stopPropagation();
} else if (this.isLookupOpen()) {
event.stopPropagation();
this.closeMenu();
}
}
if (isNavigationKey(keyCode) && this.isLookupOpen()) {
event.preventDefault();
event.stopPropagation();
if (this.keyHandlerMap[keyCode]) {
this.keyHandlerMap[keyCode]();
}
}
}
stopArrowScoll() {
if (this.scrollingTimer) {
clearTimeout(this.scrollingTimer);
}
}
scrollTo(offset) {
const menu = this.menuRef.current.getRef();
menu.scrollTo(0, offset);
}
scrollBy(offset) {
const menu = this.menuRef.current.getRef();
menu.scrollBy(0, offset);
}
handleScrollUpArrowHover() {
this.stopArrowScoll();
const menu = this.menuRef.current.getRef();
this.scrollingTimer = setTimeout(() => {
if (menu.scrollTop > 0) {
menu.scrollBy(0, -1);
setTimeout(this.handleScrollUpArrowHover(), 5);
} else {
this.stopArrowScoll();
}
}, 5);
this.updateScrollingArrows();
}
handleScrollDownArrowHover() {
this.stopArrowScoll();
const menu = this.menuRef.current.getRef();
this.scrollingTimer = setTimeout(() => {
if (!isScrollPositionAtMenuBottom(menu)) {
menu.scrollBy(0, 1);
setTimeout(this.handleScrollDownArrowHover(), 5);
} else {
this.stopArrowScoll();
}
}, 5);
this.updateScrollingArrows();
}
updateScrollingArrows() {
const menu = this.menuRef.current.getRef();
const showScrollUpArrow = menu.scrollTop > 0;
const showScrollDownArrow = !isScrollPositionAtMenuBottom(menu);
this.setState({
showScrollUpArrow,
showScrollDownArrow,
});
}
handleKeyUpPressed() {
const { focusedItemIndex, options } = this.state;
if (focusedItemIndex > 0) {
const prevIndex = focusedItemIndex - 1;
const prevFocusedIndex =
options[prevIndex].type === 'header' ? focusedItemIndex - 2 : prevIndex;
if (prevFocusedIndex >= 0) {
this.setState({
focusedItemIndex: prevFocusedIndex,
});
}
this.scrollUp(prevFocusedIndex);
}
}
scrollUp(prevFocusedIndex) {
const { options } = this.state;
const { size } = this.props;
const menu = this.menuRef.current.getRef();
const prevIndex = prevFocusedIndex >= 0 ? prevFocusedIndex : 0;
const prevFocusedOption = menu.childNodes[prevIndex];
const visibleOptionsAmount = visibleOptionsMap[size] || visibleOptionsMap.medium;
if (options.length > visibleOptionsAmount && !isOptionVisible(prevFocusedOption, menu)) {
this.menuRef.current.scrollTo(OPTION_HEIGHT * prevIndex);
}
}
handleKeyDownPressed() {
const { focusedItemIndex, options } = this.state;
const lastIndex = options.length - 1;
if (focusedItemIndex < lastIndex) {
const nextIndex = focusedItemIndex + 1;
const nextFocusedIndex =
options[nextIndex].type === 'header' ? focusedItemIndex + 2 : nextIndex;
if (nextFocusedIndex <= lastIndex) {
this.setState({
focusedItemIndex: nextFocusedIndex,
});
this.scrollDown(nextFocusedIndex);
}
}
}
scrollDown(nextFocusedIndex) {
const { options } = this.state;
const { size } = this.props;
const menu = this.menuRef.current.getRef();
const nextFocusedOption = menu.childNodes[nextFocusedIndex];
const visibleOptionsAmount = visibleOptionsMap[size] || visibleOptionsMap.medium;
if (options.length > visibleOptionsAmount && !isOptionVisible(nextFocusedOption, menu)) {
this.menuRef.current.scrollTo(
OPTION_HEIGHT * (nextFocusedIndex - (visibleOptionsAmount - 1)),
);
}
}
handleKeyEnterPressed() {
const { onChange } = this.props;
const { focusedItemIndex } = this.state;
const { options } = this.state;
const value = options[focusedItemIndex];
this.containerRef.current.focus();
this.setState({
searchValue: '',
});
onChange(value);
}
handleWindowScroll(event) {
if (this.menuRef.current && this.menuRef.current.getRef().contains(event.target)) return;
this.closeMenu();
}
handleOverlayOpened() {
this.windowScrolling.startListening(this.handleWindowScroll);
}
/**
* Sets focus on the element.
* @public
*/
focus() {
this.inputRef.current.focus();
}
/**
* Sets click on the element.
* @public
*/
click() {
this.inputRef.current.click();
}
/**
* Sets blur on the element.
* @public
*/
blur() {
this.inputRef.current.blur();
}
render() {
const {
style,
className,
label,
error,
size,
placeholder,
disabled,
readOnly,
tabIndex,
onClick,
required,
id,
name,
labelAlignment,
hideLabel,
isLoading,
icon,
variant,
borderRadius,
} = this.props;
const { searchValue, focusedItemIndex, options } = this.state;
const onDeleteValue = disabled || readOnly ? undefined : this.handleRemoveValue;
const isLookupOpen = this.isLookupOpen();
const errorMessageId = this.getErrorMessageId();
const currentValue = this.getValue();
const { showScrollUpArrow, showScrollDownArrow } = this.state;
const errorValue = isLoading ? null : error;
return (
<StyledContainer
id={id}
className={className}
style={style}
role="presentation"
onKeyDown={this.handleKeyDown}
ref={this.containerRef}
tabIndex={-1}
>
<Label
label={label}
labelAlignment={labelAlignment}
hideLabel={hideLabel}
required={required}
inputId={this.inputId}
readOnly={readOnly}
/>
<RenderIf isTrue={currentValue}>
<SelectedValue
id={this.inputId}
name={name}
value={currentValue}
tabIndex={tabIndex}
onClick={onClick}
disabled={disabled}
error={errorValue}
required={required}
readOnly={readOnly}
errorMessageId={errorMessageId}
ref={this.inputRef}
onClearValue={onDeleteValue}
borderRadius={borderRadius}
/>
</RenderIf>
<RenderIf isTrue={!currentValue}>
<StyledInputContainer
aria-expanded={isLookupOpen}
aria-haspopup="listbox"
// eslint-disable-next-line jsx-a11y/role-has-required-aria-props
role="combobox"
>
<StyledSpinner
isVisible={isLoading}
size="x-small"
assistiveText="searching"
/>
<RightElement
showCloseButton={!!searchValue}
onClear={this.clearInput}
icon={icon}
error={errorValue}
/>
<StyledInput
id={this.inputId}
name={name}
type="search"
value={searchValue}
placeholder={placeholder}
onChange={this.handleSearch}
tabIndex={tabIndex}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
onClick={this.handleClick}
disabled={disabled}
readOnly={readOnly}
required={required}
autoComplete="off"
aria-describedby={errorMessageId}
aria-autocomplete="list"
aria-controls={this.listboxId}
aria-activedescendant={this.getAriaActivedescendant()}
ref={this.inputRef}
iconPosition="right"
icon={icon}
error={errorValue}
isLoading={isLoading}
variant={variant}
borderRadius={borderRadius}
/>
<InternalOverlay
isVisible={isLookupOpen}
triggerElementRef={this.inputRef}
positionResolver={lookupPositionResolver}
onOpened={this.handleOverlayOpened}
>
<StyledOptionsMenu
id={this.listboxId}
role="listbox"
data-id="lookup-options-container"
borderRadius={borderRadius}
>
<RenderIf isTrue={showScrollUpArrow}>
<MenuArrowButton
arrow="up"
onMouseEnter={this.handleScrollUpArrowHover}
onMouseLeave={this.stopArrowScoll}
/>
</RenderIf>
<Options
items={options}
value={searchValue}
onSelectOption={this.handleChange}
focusedItemIndex={focusedItemIndex}
onHoverOption={this.handleHover}
itemHeight={OPTION_HEIGHT}
ref={this.menuRef}
size={size}
onScroll={this.updateScrollingArrows}
/>
<RenderIf isTrue={showScrollDownArrow}>
<MenuArrowButton
arrow="down"
onMouseEnter={this.handleScrollDownArrowHover}
onMouseLeave={this.stopArrowScoll}
/>
</RenderIf>
</StyledOptionsMenu>
</InternalOverlay>
</StyledInputContainer>
</RenderIf>
<RenderIf isTrue={errorValue}>
<StyledTextError id={errorMessageId}>{error}</StyledTextError>
</RenderIf>
</StyledContainer>
);
}
}
Lookup.propTypes = {
/** Text label for the Lookup. */
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
/** Describes the position of the Lookup label. Options include left, center and right.
* This value defaults to center. */
labelAlignment: PropTypes.oneOf(['left', 'center', 'right']),
/** A boolean to hide the Lookup label. */
hideLabel: PropTypes.bool,
/** Specifies the selected value of the Lookup. */
value: PropTypes.oneOfType([
PropTypes.shape({
label: PropTypes.string,
description: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
icon: PropTypes.node,
}),
PropTypes.string,
]),
/** An array of matched options to show in a menu. */
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
description: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
icon: PropTypes.node,
}),
),
/** The name of the Lookup. */
name: PropTypes.string,
/** If set to true the onSearch event is sent when the customer finish typing. */
debounce: PropTypes.bool,
/** If is set to true, then is showed a loading symbol. */
isLoading: PropTypes.bool,
/** Text that is displayed when the field is empty, to prompt the user for a valid entry. */
placeholder: PropTypes.string,
/** Specifies that the Lookup must be filled out before submitting the form.
* This value defaults to false. */
required: PropTypes.bool,
/** Specifies that the Lookup must be filled out before submitting the form. */
error: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
/** Specifies that the Lookup element should be disabled. This value defaults to false. */
disabled: PropTypes.bool,
/** Specifies that the Lookup is read-only. This value defaults to false. */
readOnly: PropTypes.bool,
/** The icon that appears in the Lookup when the input search is empty.
* If not passed by default a search icon will be showed. */
icon: PropTypes.node,
/** The size of the Lookup menu. Options include small, medium, or large.
* This value defaults to medium. */
size: PropTypes.oneOf(['small', 'medium', 'large']),
/** Specifies the tab order of an element (when the tab button is used for navigating). */
tabIndex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
/** The action triggered for every key stroke when the customer is typing in the input.
* It sent the value/query of the input. This value is normally used for filter/search
* for more options. */
onSearch: PropTypes.func,
/** The action triggered when click/select an option. */
onChange: PropTypes.func,
/** The action triggered when the element is clicked. */
onClick: PropTypes.func,
/** The action triggered when the element receives focus. */
onFocus: PropTypes.func,
/** The action triggered when the element releases focus. */
onBlur: PropTypes.func,
/** The id of the outer element. */
id: PropTypes.string,
/** A CSS class for the outer element, in addition to the component's base classes. */
className: PropTypes.string,
/** An object with custom style applied to the outer element. */
style: PropTypes.object,
/** The index of the option that is visual-focus initially */
preferredSelectedOption: PropTypes.number,
/** The variant changes the appearance of the Input. Accepted variants include default,
* shaded and bare. This value defaults to default. */
variant: PropTypes.oneOf(['default', 'shaded', 'bare']),
/** The border radius of the input. Valid values are square, semi-square, semi-rounded and rounded. This value defaults to rounded. */
borderRadius: PropTypes.oneOf(['square', 'semi-square', 'semi-rounded', 'rounded']),
};
Lookup.defaultProps = {
label: undefined,
value: undefined,
name: undefined,
placeholder: null,
required: false,
error: null,
disabled: false,
readOnly: false,
icon: <StyledSearchIcon />,
size: 'medium',
onChange: () => {},
tabIndex: undefined,
onClick: () => {},
onFocus: () => {},
onBlur: () => {},
className: undefined,
style: undefined,
id: undefined,
labelAlignment: 'center',
hideLabel: false,
isLoading: false,
options: undefined,
onSearch: () => {},
debounce: false,
preferredSelectedOption: 0,
variant: 'default',
borderRadius: 'rounded',
};
export default withReduxForm(Lookup);