UNPKG

@wix/design-system

Version:

@wix/design-system

735 lines 31.2 kB
import PropTypes from 'prop-types'; import React, { PureComponent } from 'react'; import Loader from '../Loader/Loader'; import InfiniteScroll from '../utils/InfiniteScroll'; import scrollIntoView from '../utils/scrollIntoView'; import { DATA_HOOKS, DATA_OPTION, DATA_SHOWN, DATA_DIRECTION, DROPDOWN_LAYOUT_DIRECTIONS, OPTION_DATA_HOOKS, DROPDOWN_LAYOUT_LOADER, DATA_SELECTED_OPTION_ID, } from './DataAttr'; import { st, classes } from './DropdownLayout.st.css.js'; import { filterObject } from '../utils/filterObject'; import { listItemSectionBuilder } from '../ListItemSection'; import { listItemSelectBuilder } from '../ListItemSelect'; import { listItemActionBuilder } from '../ListItemAction'; import { isString } from '../utils/StringUtils'; import { withOverlayScrollbar, } from './withOverlayScrollbar'; import { DIVIDER_OPTION_VALUE, KEY, ListType, MOUSE_EVENTS_SUPPORTED, NOT_HOVERED_INDEX, Scrollbar, } from './DropdownLayout.constants'; import { forkRef } from '../utils/forkRef'; const modulus = (n, m) => ((n % m) + m) % m; const getUnit = (value) => isString(value) ? value : `${value}px`; export class DropdownLayout extends PureComponent { constructor(props) { super(props); this.setScrollElement = (node) => { this.setState({ scrollElement: node }); }; this.containerRef = React.createRef(); this.optionsRef = React.createRef(); this.loadedWithUndefinedOptions = false; this._boundEvents = []; this.focusableItemsIdsList = []; this.savedOnClicks = []; this.children = {}; this.selectedOption = null; /* @deprecated */ this._onMouseEventsHandler = (e) => { if (!this._checkIfEventOnElements(e, [this.containerRef.current])) { this._onClickOutside(e); } }; this._onClickOutside = (event) => { const { visible, onClickOutside } = this.props; if (visible && onClickOutside) { onClickOutside(event); } }; this._onSelect = (index, e) => { const { onSelect, listType } = this.props; const options = this.props.options || []; if (listType !== ListType.select) { e.stopPropagation(); this._onClose(); return; } const chosenOption = options[index]; if (chosenOption) { const sameOptionWasPicked = chosenOption.id === this.state.selectedId; if (onSelect) { e.stopPropagation(); onSelect( // casting to avoid breaking changes after TS conversion chosenOption, sameOptionWasPicked); } } if (!this._isControlled()) { this.setState({ selectedId: chosenOption && chosenOption.id }); } return !!onSelect && chosenOption; }; this._onActionClick = (e) => { const onClick = this.savedOnClicks.find(({ id }) => id === e.id)?.onClick; onClick && onClick(e); }; this._saveOnClicks = () => { const options = this.props.options || []; this.savedOnClicks = options.map(({ id, // only builder options have onClick // @ts-expect-error onClick, }) => ({ id, onClick, })); }; this._onMouseEnter = (index) => { const { options = [] } = this.props; if (this._isSelectableOption(options[index])) { const markedOption = options[index]; this.props.onOptionsNavigate?.(markedOption); this._markOption(index); } }; this._onMouseLeave = () => { this._markOption(NOT_HOVERED_INDEX); }; this._onContainerMouseLeave = (e) => { this.props.onOptionsNavigate?.(null); this._markOption(NOT_HOVERED_INDEX); if (this.props.onMouseLeave) { this.props.onMouseLeave(e); } }; this._focusOnOption = () => { const { focusOnOption } = this.props; const options = this.props.options || []; const markedIndex = options.findIndex(option => option.id === focusOnOption); if (markedIndex !== -1) { this._markOptionAtIndex(markedIndex); } else { // Remove focus this._markOption(markedIndex); } }; this._markOptionAtIndex = (markedIndex) => { this._markOption(markedIndex); this._scrollIntoViewByIndex(markedIndex); }; this._scrollIntoViewByIndex = (index) => { const { infiniteScroll } = this.props; const menuElement = this.optionsRef.current; const hoveredElement = infiniteScroll ? this.optionsRef.current?.childNodes[0].childNodes[index] : this.optionsRef.current?.childNodes[index]; scrollIntoView(menuElement, hoveredElement); }; /** * Handle keydown events for the DropdownLayout, mostly for accessibility * * @param event - The keydown event triggered by React * @returns Whether the event was handled by the component */ this._onSelectListKeyDown = (event) => { if (!this.props.visible || // such prop does not exist but let's avoid potential breaking change // @ts-expect-error this.props.isComposing || this.props.listType !== ListType.select) { return false; } switch (event.key) { case KEY.arrowDown: { this._markNextStep(1); event.preventDefault(); break; } case KEY.arrowUp: { this._markNextStep(-1); event.preventDefault(); break; } case KEY.space: case KEY.enter: { if (!this._onSelect(this.state.hovered, event)) { return false; } break; } case KEY.tab: { if (this.props.closeOnSelect) { return this._onSelect(this.state.hovered, event); } else { if (this._onSelect(this.state.hovered, event)) { event.preventDefault(); return true; } else { return false; } } } case KEY.escape: { this._onClose(); break; } default: { return false; } } event.stopPropagation(); return true; }; this._focus = (focusedItemId, e) => { e && e.preventDefault(); const element = this.children[focusedItemId]; if (!element) { return; } const native = element.focus; const focusableHOC = element.wrappedComponentRef; const callback = native ? element.focus : focusableHOC ? focusableHOC.innerComponentRef.focus : () => ({}); this.setState({ focusedItemId }, () => callback?.()); }; this._handleActionListNavigation = (event, id) => { const length = this.focusableItemsIdsList.length; let focusedItemId = this.state.focusedItemId; const { key } = event; const currentMenuItemIndex = this.focusableItemsIdsList.indexOf(id); const firstMenuItem = this.focusableItemsIdsList[0]; const lastMenuItem = this.focusableItemsIdsList[length - 1]; if (key === KEY.arrowLeft || key === KEY.arrowUp) { focusedItemId = id === 0 ? lastMenuItem : this.focusableItemsIdsList[currentMenuItemIndex - 1]; } if (key === KEY.arrowRight || key === KEY.arrowDown) { focusedItemId = currentMenuItemIndex === length - 1 ? firstMenuItem : this.focusableItemsIdsList[currentMenuItemIndex + 1]; } if (key === KEY.home) { focusedItemId = firstMenuItem; } if (key === KEY.end) { focusedItemId = lastMenuItem; } if (focusedItemId !== this.state.focusedItemId) { this._focus(focusedItemId, event); this._scrollIntoViewByIndex(focusedItemId); } }; this._onActionListKeyDown = (event, id) => { if (this.props.listType !== ListType.action) { return; } const { key } = event; if (key === KEY.space || key === KEY.enter) { event.preventDefault(); this._onActionClick({ id: this.state.focusedItemId }); this._onClose(); } else if (key === KEY.escape || key === KEY.tab) { this._onClose(); } else { this._handleActionListNavigation(event, id); } event.stopPropagation(); }; this._onClose = () => { this.props.onOptionsNavigate?.(null); this._markOption(NOT_HOVERED_INDEX); if (this.props.onClose) { this.props.onClose(); } }; this._wrapWithInfiniteScroll = (scrollableElement) => { if (!this.optionsRef.current) { this.loadedWithUndefinedOptions = true; } return (React.createElement(InfiniteScroll, { useWindow: true, dataHook: DATA_HOOKS.INFINITE_SCROLL_CONTAINER, scrollElement: this.state.scrollElement, loadMore: this.props.loadMore, hasMore: this.props.hasMore, data: this.props.options, loader: React.createElement("div", { className: classes.loader }, React.createElement(Loader, { dataHook: DROPDOWN_LAYOUT_LOADER, size: "small" })) }, scrollableElement)); }; /** for testing purposes only */ this._getDataAttributes = () => { const { visible, dropDirectionUp } = this.props; const { selectedId } = this.state; return filterObject({ 'data-hook': DATA_HOOKS.CONTENT_CONTAINER, [DATA_SHOWN]: visible, [DATA_SELECTED_OPTION_ID]: selectedId === 0 ? `${selectedId}` : selectedId, [DATA_DIRECTION]: dropDirectionUp ? DROPDOWN_LAYOUT_DIRECTIONS.UP : DROPDOWN_LAYOUT_DIRECTIONS.DOWN, }, (_, value) => !!value); }; // For testing purposes only this._getItemDataAttr = ({ hovered, selected, disabled, }) => { const { itemHeight, selectedHighlight } = this.props; return filterObject({ [DATA_OPTION.DISABLED]: disabled, [DATA_OPTION.SELECTED]: selected && selectedHighlight, [DATA_OPTION.HOVERED]: hovered, /* deprecated */ [DATA_OPTION.SIZE]: itemHeight, }, (_, value) => !!value); }; this._isSelectableOption = (option) => { if (!option) { return false; } if (this._isDivider(option)) { return false; } // only DropdownLayoutValueOption type has title prop, which is deprecated // therefore expecting error instead of dealing with it // @ts-expect-error const hasTitle = option.title; return !this._isDivider(option) && !option.disabled && !hasTitle; }; this.state = { hovered: NOT_HOVERED_INDEX, selectedId: props.selectedId, scrollElement: null, focusedItemId: null, }; } componentDidMount() { if (this.loadedWithUndefinedOptions && this.optionsRef.current) { this.forceUpdate(); } const { focusOnSelectedOption, scrollToOption, autoFocus } = this.props; if (focusOnSelectedOption) { this._focusOnSelectedOption(); } else if (this.props.hasOwnProperty('focusOnOption')) { this._focusOnOption(); } if (scrollToOption) { this._scrollToOption(); } this._markOptionByProperty(this.props); /* @deprecated */ MOUSE_EVENTS_SUPPORTED.forEach(eventName => { document.addEventListener(eventName, this._onMouseEventsHandler, true); }); this._boundEvents = MOUSE_EVENTS_SUPPORTED; if (autoFocus) { this._focusFirstOption(); } } componentWillUnmount() { if (this._boundEvents && typeof document !== 'undefined') { this._boundEvents.forEach(eventName => { document.removeEventListener(eventName, this._onMouseEventsHandler, true); }); } } componentDidUpdate(prevProps) { const { focusOnOption } = this.props; if (prevProps.focusOnOption !== focusOnOption) { this._focusOnOption(); } } UNSAFE_componentWillReceiveProps(nextProps) { const options = this.props.options || []; const nextOptions = nextProps.options || []; if (this.props.visible !== nextProps.visible) { this._markOption(NOT_HOVERED_INDEX); } if (this.props.selectedId !== nextProps.selectedId) { this.setState({ selectedId: nextProps.selectedId }); } // make sure the same item is hovered if options changed if (this.state.hovered !== NOT_HOVERED_INDEX && (!nextOptions[this.state.hovered] || options[this.state.hovered].id !== nextOptions[this.state.hovered].id)) { this._markOption(this._findIndex(nextProps.options ?? [], item => item.id === options[this.state.hovered].id)); } this._markOptionByProperty(nextProps); } _getOptionElementId(optionIndex) { return `dropdown-item-${optionIndex}`; } _focusFirstOption() { this._focus(this.focusableItemsIdsList[0]); } /* @deprecated */ _checkIfEventOnElements(e, elem) { let current = e.target; while (current.parentNode) { if (elem.indexOf(current) > -1) { return true; } current = current.parentNode; } return current !== document; } /* @deprecated */ _renderTopArrow() { const { withArrow, visible } = this.props; return withArrow && visible ? (React.createElement("div", { "data-hook": DATA_HOOKS.TOP_ARROW, className: classes.arrow })) : null; } _convertOptionToListItemSectionBuilder({ option, idx, }) { if (this._isDivider(option)) { return listItemSectionBuilder({ dataHook: OPTION_DATA_HOOKS.DIVIDER, id: option.id || idx, type: 'divider', }); } else if (option.title) { return listItemSectionBuilder({ dataHook: OPTION_DATA_HOOKS.TITLE, id: option.id, type: 'subheader', title: option.value, }); } else { // This should never happen but let's make TS happy return undefined; } } _convertOptionToListItemActionBuilder({ option, idx, }) { const { id, value, disabled, disabledDescription, optionTitle, title, ...rest } = option; const { size } = this.props; return listItemActionBuilder({ id: id !== undefined ? id : idx, ref: (ref) => (this.children[id] = ref), tabIndex: id === this.state.focusedItemId && !disabled ? '0' : '-1', disabled, disabledDescription, title: optionTitle, role: 'menuitem', size, ...rest, }); } _isControlled() { return (typeof this.props.selectedId !== 'undefined' && typeof this.props.onSelect !== 'undefined'); } _focusOnSelectedOption() { if (this.selectedOption && this.optionsRef.current) { this.optionsRef.current.scrollTop = Math.max(this.selectedOption.offsetTop - this.selectedOption.offsetHeight, 0); } } _setSelectedOptionNode(optionNode, option) { if (option.id === this.state.selectedId) { this.selectedOption = optionNode; } } _markOption(index, options) { const { onOptionMarked } = this.props; options = options || this.props.options || []; this.setState({ hovered: index }); if (onOptionMarked) { const markedOption = options[index] || null; const markedOptionElementId = index === NOT_HOVERED_INDEX ? undefined : this._getOptionElementId(index); // the type declaration was incorrect, casting to avoid breaking changes after TS conversion onOptionMarked(markedOption, markedOptionElementId); } } _getMarkedIndex() { const options = this.props.options || []; const useHoverIndex = this.state.hovered > NOT_HOVERED_INDEX; const useSelectedIdIndex = typeof this.state.selectedId !== 'undefined'; let markedIndex; if (useHoverIndex) { markedIndex = this.state.hovered; } else if (useSelectedIdIndex) { markedIndex = options.findIndex(option => option.id === this.state.selectedId); } else { markedIndex = NOT_HOVERED_INDEX; } return markedIndex; } getActiveDescendentElementId() { const options = this.props.options || []; let markedIndex = this._getMarkedIndex(); if (markedIndex === NOT_HOVERED_INDEX) { markedIndex = options.findIndex(this._isSelectableOption); } return markedIndex === NOT_HOVERED_INDEX ? undefined : this._getOptionElementId(markedIndex); } _markNextStep(step) { const { onOptionsNavigate, options = [] } = this.props; if (!options.some(this._isSelectableOption)) { return; } let markedIndex = this._getMarkedIndex(); do { markedIndex = modulus(Math.max(markedIndex + step, -1), options.length); } while (!this._isSelectableOption(options[markedIndex])); const markedOption = options[markedIndex]; onOptionsNavigate?.(markedOption); this._markOptionAtIndex(markedIndex); } _scrollToOption() { const { scrollToOption } = this.props; const options = this.props.options || []; const optionIndex = options.findIndex(option => option.id === scrollToOption); const optionNode = this.optionsRef.current?.childNodes[optionIndex]; if (!optionNode || !this.optionsRef.current) { return; } this.optionsRef.current.scrollTop = Math.max(optionNode.offsetTop - optionNode.offsetHeight, 0); } _renderNode(node) { return node ? React.createElement("div", null, node) : null; } _convertCustomOptionToBuilder({ option, }) { const { value, id, disabled, disabledDescription, overrideOptionStyle, overrideStyle, } = option; if (overrideStyle) { return { id, disabled, disabledDescription, overrideStyle, value: () => (React.createElement("div", { className: classes.dropdownOption, "data-hook": DATA_HOOKS.OPTION }, value)), }; } if (overrideOptionStyle) { return { id, disabled, disabledDescription, overrideOptionStyle, value: () => (React.createElement("div", { className: classes.dropdownOption, "data-hook": DATA_HOOKS.OPTION }, value)), }; } else { // This should never happen but let's make TS happy return undefined; } } _convertOptionToListItemSelectBuilder({ option, }) { const { value, id, disabled, disabledDescription } = option; const { selectedId } = this.state; const { itemHeight, selectedHighlight, size } = this.props; return listItemSelectBuilder({ id, title: (React.createElement("div", { "data-hook": DATA_HOOKS.OPTION }, value)), disabled, disabledDescription, size, selected: id === selectedId && selectedHighlight, className: st(classes.selectableOption, { itemHeight }), }); } _isBuilderOption(option) { const { value } = option; return typeof value === 'function'; } _isCustomOption(option) { const { overrideOptionStyle, overrideStyle } = option; return overrideOptionStyle || overrideStyle; } _isActionOption(option) { return 'value' in option && option.value === ListType.action; } _isItemSection(option) { // only DropdownLayoutValueOption type has title prop, which is deprecated // therefore expecting error instead of dealing with it // @ts-expect-error const { title: isTitle } = option; return this._isDivider(option) || isTitle; } _isDivider(opt) { return opt.value === DIVIDER_OPTION_VALUE; } _convertOptionToBuilder(option, idx) { if (this._isBuilderOption(option)) { return option; } else if (this._isActionOption(option)) { return this._convertOptionToListItemActionBuilder({ option, idx }); } else if (this._isItemSection(option)) { return this._convertOptionToListItemSectionBuilder({ option, idx }); } else if (this._isCustomOption(option)) { return this._convertCustomOptionToBuilder({ option }); } else { return this._convertOptionToListItemSelectBuilder({ option }); } } _renderOption({ option, idx, }) { const isValueOption = (opt) => 'id' in opt && 'value' in opt && typeof opt.value !== 'function'; const builderOption = this._convertOptionToBuilder(option, idx); const hasLink = isValueOption(option) && !!option.linkTo; const content = this._renderOptionContent({ option: builderOption, idx, hasLink, }); const isActionItem = this.props.listType === ListType.action; return hasLink ? (React.createElement("a", { className: classes.linkItem, key: idx, "data-hook": DATA_HOOKS.LINK_ITEM, href: option.linkTo, role: isActionItem ? undefined : 'option', "aria-selected": isActionItem || option.disabled ? undefined : option.id === this.state.selectedId, "aria-hidden": option.disabled }, content)) : (content); } _renderOptionContent({ option, idx, hasLink, }) { if (!option) { return undefined; } const { itemHeight, selectedHighlight, listType } = this.props; const { selectedId, hovered } = this.state; const { id, disabled, // Only ListItemAction and ListItemSelect have disabledDescription prop // @ts-expect-error disabledDescription, // only ConvertedCustomOption type has overrideStyle prop, which is deprecated // therefore expecting error instead of dealing with it // @ts-expect-error overrideStyle, overrideOptionStyle, } = option; const optionState = { selected: id === selectedId, hovered: idx === hovered, disabled, size: this.props.size, }; const isDisabledWithTooltip = disabled && !!disabledDescription; if (!disabled || isDisabledWithTooltip) { this.focusableItemsIdsList = [...this.focusableItemsIdsList, id]; } const isActionItem = listType === ListType.action; return (React.createElement("div", { "aria-selected": hasLink || isActionItem || disabled ? undefined : optionState.selected, "aria-hidden": disabled, ...this._getItemDataAttr({ ...optionState }), role: hasLink || isActionItem ? undefined : 'option', className: overrideOptionStyle ? null : st(classes.option, { ...optionState, selected: optionState.selected && selectedHighlight, itemHeight, overrideStyle, }), id: this._getOptionElementId(idx), ref: node => this._setSelectedOptionNode(node, option), onClick: !disabled ? e => this._onSelect(idx, e) : undefined, key: idx, onMouseEnter: () => this._onMouseEnter(idx), onMouseLeave: this._onMouseLeave, "data-hook": `dropdown-item-${id}`, onKeyDown: e => this._onActionListKeyDown(e, id) }, option?.value(optionState))); } _markOptionByProperty(props) { const options = props.options || []; if (this.state.hovered === NOT_HOVERED_INDEX && props.markedOption) { const selectableOptions = options.filter(this._isSelectableOption); if (selectableOptions.length) { const idToMark = props.markedOption === true ? selectableOptions[0].id : props.markedOption; const foundIndex = this._findIndex(options, (item) => item.id === idToMark); if (foundIndex !== -1) { this._markOption(foundIndex, props.options); } } } } _findIndex(arr, predicate) { return (Array.isArray(arr) ? arr : []).findIndex(predicate); } _renderOptions() { this.focusableItemsIdsList = []; this._saveOnClicks(); const options = this.props.options || []; return options.map((option, idx) => this._renderOption({ option, idx })); } render() { const { className, visible, dropDirectionUp, tabIndex, onMouseEnter, onMouseDown, fixedHeader, withArrow, fixedFooter, inContainer, overflow, maxHeightPixels = 260, minWidthPixels, infiniteScroll, dataHook, listType, overlayScrollbarProps, listboxId, } = this.props; const renderedOptions = this._renderOptions(); const OverlayScrollbarHostElement = overlayScrollbarProps?.OverlayScrollbarHostElement; const OverlayScrollbarContentElement = overlayScrollbarProps?.OverlayScrollbarContentElement; const optionsContainerProps = { style: { maxHeight: getUnit(parseInt(String(maxHeightPixels), 10) - 35), overflow, }, 'data-hook': DATA_HOOKS.DROPDOWN_LAYOUT_OPTIONS, role: listType === ListType.select ? 'listbox' : 'menu', id: listboxId, }; const options = infiniteScroll ? this._wrapWithInfiniteScroll(renderedOptions) : renderedOptions; const optionsContainer = this.props.scrollbar === Scrollbar.OVERLAY && OverlayScrollbarHostElement && OverlayScrollbarContentElement ? (React.createElement(OverlayScrollbarHostElement, { className: classes.options, ...optionsContainerProps }, React.createElement(OverlayScrollbarContentElement, { className: classes.optionsContent, ref: forkRef(this.optionsRef, this.setScrollElement) }, options))) : (React.createElement("div", { className: st(classes.options, classes.optionsContent), ref: forkRef(this.optionsRef, this.setScrollElement), ...optionsContainerProps }, options)); return (React.createElement("div", { "data-list-type": listType, "data-hook": dataHook, className: st(classes.root, { visible, withArrow, direction: dropDirectionUp ? DROPDOWN_LAYOUT_DIRECTIONS.UP : DROPDOWN_LAYOUT_DIRECTIONS.DOWN, containerStyles: !inContainer, }, className), tabIndex: tabIndex, onKeyDown: this._onSelectListKeyDown, onMouseEnter: onMouseEnter, onMouseLeave: this._onContainerMouseLeave, onMouseDown: onMouseDown, ref: this.containerRef }, React.createElement("div", { ...this._getDataAttributes(), className: classes.contentContainer, style: { overflow, maxHeight: maxHeightPixels ? getUnit(maxHeightPixels) : undefined, minWidth: minWidthPixels ? getUnit(minWidthPixels) : undefined, } }, this._renderNode(fixedHeader), optionsContainer, this._renderNode(fixedFooter)), this._renderTopArrow())); } } DropdownLayout.propTypes = { className: PropTypes.any, dropDirectionUp: PropTypes.any, focusOnSelectedOption: PropTypes.any, onClose: PropTypes.any, onSelect: PropTypes.any, onOptionMarked: PropTypes.any, onOptionsNavigate: PropTypes.any, overflow: PropTypes.any, visible: PropTypes.any, options: PropTypes.any, selectedId: PropTypes.any, tabIndex: PropTypes.any, onClickOutside: PropTypes.any, fixedHeader: PropTypes.any, fixedFooter: PropTypes.any, maxHeightPixels: PropTypes.any, minWidthPixels: PropTypes.any, withArrow: PropTypes.any, closeOnSelect: PropTypes.any, onMouseEnter: PropTypes.any, onMouseLeave: PropTypes.any, itemHeight: PropTypes.any, selectedHighlight: PropTypes.any, inContainer: PropTypes.any, infiniteScroll: PropTypes.any, loadMore: PropTypes.any, hasMore: PropTypes.any, markedOption: PropTypes.any, focusOnOption: PropTypes.any, scrollToOption: PropTypes.any, listType: PropTypes.any, autoFocus: PropTypes.any, scrollbar: PropTypes.any, }; DropdownLayout.defaultProps = { options: [], tabIndex: 0, maxHeightPixels: 260, closeOnSelect: true, itemHeight: 'small', selectedHighlight: true, inContainer: false, infiniteScroll: false, loadMore: undefined, hasMore: false, markedOption: false, overflow: 'auto', listType: ListType.select, scrollbar: 'fixed', }; DropdownLayout.displayName = 'DropdownLayout'; DropdownLayout.NONE_SELECTED_ID = NOT_HOVERED_INDEX; export default withOverlayScrollbar(DropdownLayout); //# sourceMappingURL=DropdownLayout.js.map