src/components/InternalDropdown/index.js
import React, {
useState,
useRef,
useEffect,
useMemo,
useCallback,
forwardRef,
useImperativeHandle,
} from 'react';
import PropTypes from 'prop-types';
import RenderIf from '../RenderIf';
import { UP_KEY, DOWN_KEY, SPACE_KEY, ENTER_KEY } from '../../libs/constants';
import { Provider } from './context';
import Content from './content';
import isChildRegistered from './helpers/isChildRegistered';
import getChildMenuItemNodes from './helpers/getChildMenuItemNodes';
import insertChildOrderly from './helpers/insertChildOrderly';
import isScrollPositionAtMenuBottom from './helpers/isScrollPositionAtMenuBottom';
import isOptionVisible from './helpers/isOptionVisible';
import scrollTo from './helpers/scrollTo';
import searchFilter from './helpers/searchFilter';
import getValueNames from './helpers/getValueNames';
import isEmptyObject from './helpers/isEmptyObject';
import EmptyMessage from './emptyMessage';
import { Dropdown, Ul, Arrow, InputSearch, UlContainer, SearchContainer, Icon } from './styled';
import PlaceholderOption from './placeholderOption';
import isChecked from './helpers/isChecked';
import getAllValues from './helpers/getAllValues';
const sizeMap = {
medium: 227,
};
const preventDefaultKeys = {
[UP_KEY]: true,
[DOWN_KEY]: true,
};
const menuContainerStyles = {
maxHeight: sizeMap.medium,
};
/**
* @category Internal
*/
const InternalDropdown = forwardRef((props, reference) => {
const {
isLoading,
children,
value,
onChange,
enableSearch,
id,
className,
style,
multiple,
showCheckbox,
placeholder,
onSearch,
debounce,
emptyComponent: EmptyComponent,
borderRadius,
} = props;
const [showScrollUpArrow, setShowScrollUpArrow] = useState(false);
const [showScrollDownArrow, setShowScrollDownArrow] = useState(false);
const [activeOptionName, setActiveOptionName] = useState(null);
const [activeOptionIndex, setActiveOptionIndex] = useState(0);
const [activeChildrenMap, setActiveChildrenMap] = useState();
const [searchValue, setSearchValue] = useState('');
const activeChildren = useRef([]);
const allActiveChildren = useRef();
const firstChild = useRef();
const menuRef = useRef();
const containerRef = useRef();
const scrollingTimer = useRef();
const searchRef = useRef();
const searchTimeout = useRef();
const showEmptyMessage =
enableSearch && onSearch
? !isLoading && React.Children.count(children) === 0
: !isLoading && isEmptyObject(activeChildrenMap);
useImperativeHandle(reference, () => ({
focus: () => {
if (enableSearch) {
return searchRef.current.focus();
}
return containerRef.current.focus();
},
contains: element => containerRef.current.contains(element),
}));
const isComponentMounted = useRef(true);
useEffect(() => {
return () => {
isComponentMounted.current = false;
};
}, []);
const updateScrollingArrows = () => {
const menu = menuRef.current;
setShowScrollUpArrow(menu.scrollTop > 0);
setShowScrollDownArrow(!isScrollPositionAtMenuBottom(menu));
};
const resetActiveChild = () => {
setActiveOptionIndex(0);
const firstActiveChild = activeChildren.current[0];
if (firstActiveChild) {
setActiveOptionName(firstActiveChild.name);
}
};
const scrollBy = offset => {
menuRef.current.scrollBy(0, offset);
};
useEffect(() => {
scrollTo(menuRef, 0);
updateScrollingArrows();
}, []);
const registerChild = useCallback((childRef, childProps) => {
if (!isComponentMounted.current) return;
if (isChildRegistered(childProps.name, activeChildren.current)) return;
const [...nodes] = getChildMenuItemNodes(containerRef.current);
const newChildren = insertChildOrderly(
activeChildren.current,
{
ref: childRef,
...childProps,
},
nodes,
);
activeChildren.current = newChildren;
if (onSearch) {
setActiveOptionIndex(0);
setActiveOptionName(newChildren[0].name);
firstChild.current = newChildren[0].name;
} else if (!firstChild.current) {
firstChild.current = childProps;
setActiveOptionName(childProps.name);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const unregisterChild = useCallback((childRef, name) => {
if (!isComponentMounted.current) return;
if (!isChildRegistered(name, activeChildren.current)) return;
const newChildren = activeChildren.current.filter(child => child.name !== name);
activeChildren.current = newChildren;
if (activeChildren.current.length === 0) {
firstChild.current = undefined;
}
if (onSearch && firstChild.current === name) {
resetActiveChild();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const hoverChild = useCallback((event, name) => {
setActiveOptionName(name);
setActiveOptionIndex(activeChildren.current.findIndex(child => child.name === name));
}, []);
const stopArrowScoll = () => {
if (scrollingTimer.current) clearInterval(scrollingTimer.current);
};
const handleScrollUpArrowHover = () => {
stopArrowScoll();
const menu = menuRef.current;
scrollingTimer.current = setInterval(() => {
if (menu.scrollTop > 0) {
menu.scrollBy(0, -1);
} else {
stopArrowScoll();
}
}, 5);
};
const handleScrollDownArrowHover = () => {
stopArrowScoll();
const menu = menuRef.current;
scrollingTimer.current = setInterval(() => {
if (!isScrollPositionAtMenuBottom(menu)) {
menu.scrollBy(0, 1);
} else {
stopArrowScoll();
}
}, 5);
};
const scrollToOption = nextFocusedIndex => {
const currentFocusedOptionRef = activeChildren.current[activeOptionIndex].ref;
const nextFocusedOptionRef = activeChildren.current[nextFocusedIndex].ref;
if (!isOptionVisible(nextFocusedOptionRef, menuRef.current)) {
const amount = nextFocusedOptionRef.offsetTop - currentFocusedOptionRef.offsetTop;
scrollBy(amount);
}
};
const resetTimeout = () => {
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
searchTimeout.current = undefined;
}
};
const fireSearch = query => {
resetTimeout();
if (debounce) {
searchTimeout.current = setTimeout(() => {
resetTimeout();
onSearch(query);
}, 500);
} else {
onSearch(query);
}
setSearchValue(query);
};
const handleChange = useCallback(
option => {
const { icon, name, label, value: optionValue, only } = option;
if (only) {
return onChange([
{
label,
name,
icon,
value: optionValue,
},
]);
}
if (multiple) {
if (Array.isArray(value)) {
if (value.some(item => item.name === name)) {
return onChange(value.filter(item => item.name !== name));
}
return onChange(value.concat([option]));
}
if (value) {
return onChange([value, option]);
}
return onChange([option]);
}
return onChange({
label,
name,
icon,
value: optionValue,
});
},
[multiple, value, onChange],
);
const handleKeyUpPressed = () => {
const nextActiveIndex =
(activeChildren.current.length + activeOptionIndex - 1) % activeChildren.current.length;
if (nextActiveIndex < activeOptionIndex) {
if (nextActiveIndex === 0) {
scrollTo(menuRef, 0);
} else {
scrollToOption(nextActiveIndex);
}
setActiveOptionIndex(nextActiveIndex);
setActiveOptionName(activeChildren.current[nextActiveIndex].name);
}
};
const handleKeyDownPressed = () => {
const nextActiveIndex = (activeOptionIndex + 1) % activeChildren.current.length;
if (nextActiveIndex > 0) {
scrollToOption(nextActiveIndex);
setActiveOptionIndex(nextActiveIndex);
setActiveOptionName(activeChildren.current[nextActiveIndex].name);
}
};
const handleKeyEnterPressed = () => {
const { ref, ...rest } = activeChildren.current[activeOptionIndex];
return handleChange(rest);
};
const keyHandlerMap = {
[UP_KEY]: handleKeyUpPressed,
[DOWN_KEY]: handleKeyDownPressed,
[ENTER_KEY]: handleKeyEnterPressed,
};
const handleKeyPressed = event => {
if (event.keyCode === SPACE_KEY && !enableSearch) {
event.preventDefault();
}
if (preventDefaultKeys[event.keyCode]) event.preventDefault();
if (keyHandlerMap[event.keyCode]) {
keyHandlerMap[event.keyCode](event);
}
};
const handleSearch = event => {
if (!allActiveChildren.current) {
allActiveChildren.current = [...activeChildren.current];
}
if (onSearch) {
setActiveChildrenMap();
fireSearch(event.target.value);
} else {
const filteredOptions = searchFilter({
query: event.target.value,
data: allActiveChildren.current,
});
setActiveChildrenMap(
filteredOptions.reduce((acc, option) => {
acc[option.name] = true;
return acc;
}, {}),
);
resetActiveChild();
setSearchValue(event.target.value);
}
setTimeout(() => updateScrollingArrows(), 0);
};
const handleTopOptionClick = () => {
if (Array.isArray(value)) {
if (value.length === 0) {
return onChange(getAllValues(activeChildren.current));
}
return onChange([]);
}
if (value) {
return onChange([]);
}
return onChange(getAllValues(activeChildren.current));
};
const context = useMemo(() => {
const currentValues = getValueNames(value);
return {
privateOnClick: (event, option) => handleChange(option),
privateRegisterChild: registerChild,
privateUnregisterChild: unregisterChild,
privateOnHover: hoverChild,
activeOptionName,
currentValues,
activeChildrenMap,
multiple,
showCheckbox,
};
}, [
value,
registerChild,
unregisterChild,
hoverChild,
activeOptionName,
activeChildrenMap,
handleChange,
multiple,
showCheckbox,
]);
const isPlaceholderOptionChecked = isChecked(value, activeChildren.current);
const shouldRenderPlaceholderOption = placeholder && showCheckbox;
const showEmptyComponent = showEmptyMessage && EmptyComponent;
return (
<Dropdown
id={id}
role="listbox"
aria-activedescendant={activeOptionName}
isLoading={isLoading}
className={className}
style={style}
onKeyDown={handleKeyPressed}
tabIndex="-1"
ref={containerRef}
borderRadius={borderRadius}
>
<RenderIf isTrue={enableSearch}>
<SearchContainer isLoading={isLoading}>
<Icon />
<InputSearch onChange={handleSearch} ref={searchRef} type="search" />
</SearchContainer>
</RenderIf>
<UlContainer>
<RenderIf isTrue={showScrollUpArrow}>
<Arrow
data-id="internal-dropdown-arrow-up"
direction="up"
onMouseEnter={handleScrollUpArrowHover}
onMouseLeave={stopArrowScoll}
/>
</RenderIf>
<Ul
role="presentation"
onScroll={updateScrollingArrows}
ref={menuRef}
style={menuContainerStyles}
showEmptyMessage={showEmptyMessage}
>
<Content isLoading={isLoading}>
<Provider value={context}>
<RenderIf isTrue={shouldRenderPlaceholderOption}>
<PlaceholderOption
name="header"
label={placeholder}
onClick={handleTopOptionClick}
isChecked={isPlaceholderOptionChecked}
tabIndex="-1"
/>
</RenderIf>
{children}
</Provider>
</Content>
</Ul>
{showEmptyComponent && <EmptyComponent searchValue={searchValue} />}
<RenderIf isTrue={showEmptyMessage && !EmptyComponent}>
<EmptyMessage searchValue={searchValue} hasTimeout={!!searchTimeout.current} />
</RenderIf>
<RenderIf isTrue={showScrollDownArrow}>
<Arrow
data-id="internal-dropdown-arrow-down"
direction="down"
onMouseEnter={handleScrollDownArrowHover}
onMouseLeave={stopArrowScoll}
/>
</RenderIf>
</UlContainer>
</Dropdown>
);
});
InternalDropdown.propTypes = {
/** 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,
/** If is set to true, then is showed a loading symbol. */
isLoading: PropTypes.bool,
/** The content of the InternalDropdown. Used to render the options
* passed. */
children: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.node), PropTypes.object]),
/** Specifies the selected value of the InternalDropdown. Must have a name which identifies de selected option.
* Also it can have whatever other key value pairs you want.
*/
value: PropTypes.oneOfType([
PropTypes.shape({
name: PropTypes.string,
}),
PropTypes.arrayOf(
PropTypes.shape({
name: PropTypes.string,
}),
),
PropTypes.string,
]),
/** The action triggered when click/select an option. */
onChange: PropTypes.func,
/** If is set to true, then a search input to filter is showed. */
enableSearch: PropTypes.bool,
/** Specifies that multiple items can be selected */
multiple: PropTypes.bool,
/** Show checkbox */
showCheckbox: PropTypes.bool,
/** The text to show on the header when showCheckbox is true */
placeholder: PropTypes.string,
/** Action triggered when search query changes */
onSearch: PropTypes.func,
/** When true, the onSearch callback will be debounced */
debounce: PropTypes.bool,
/** A component that is displayed when no search matches are found */
emptyComponent: PropTypes.node,
/** The border radius of the container. Valid values are square, semi-square, semi-rounded and rounded. This value defaults to rounded. */
borderRadius: PropTypes.oneOf(['square', 'semi-square', 'semi-rounded', 'rounded']),
};
InternalDropdown.defaultProps = {
id: undefined,
className: undefined,
style: undefined,
isLoading: false,
children: null,
value: undefined,
onChange: () => {},
enableSearch: false,
multiple: false,
showCheckbox: false,
placeholder: undefined,
onSearch: undefined,
emptyComponent: undefined,
debounce: false,
borderRadius: 'rounded',
};
export default InternalDropdown;