/** * Options list with arrow pagination * * @author: Denis Makarov * @date: 2020-10-07 */ import * as React from 'react'; import * as styles from './optionList.m.scss'; import {IOption, Value} from '..'; import {joinClassNames} from '../..'; import {OptionItem} from './OptionItem'; interface IProps { options: IOption []; onSelect: (option: IOption | null) => void; value?: Value; showUnsetOption?: boolean; forwardRef?: React.RefObject; maxHeight?: number; unsetText?: string; optionRenderer?: (option?: IOption) => React.ReactNode; } interface IState { cursorItemIndex: number | undefined; isNavigationDisabled: boolean; } export class OptionList extends React.PureComponent { override state: IState = { cursorItemIndex: undefined, isNavigationDisabled: true }; containerElement = React.createRef(); navigatedElement = React.createRef(); createRef () { if (this.props.forwardRef) { this.containerElement = this.props.forwardRef; } return this.containerElement; } handleKeyDown = (e: KeyboardEvent) => { if (this.state.isNavigationDisabled) { return; } if (e.key === 'ArrowUp') { this.setState((prevState) => { return { cursorItemIndex: this.getPrevAvailableOption(prevState.cursorItemIndex) }; }); } if (e.key === 'ArrowDown') { this.setState((prevState) => { return { cursorItemIndex: this.getNextAvailableOption(prevState.cursorItemIndex) }; }); } const cursorItemIndex = this.state.cursorItemIndex; if (e.key === 'Enter' && cursorItemIndex !== undefined) { e.preventDefault(); let el: IOption | null; if (this.props.showUnsetOption) { if (cursorItemIndex === 0) { el = null; } else { el = this.props.options[cursorItemIndex - 1]; } } else { el = this.props.options[cursorItemIndex]; } this.props.onSelect(el || null); } }; getNextAvailableOption = (currentIndex: number = -1): number => { const showUnsetOption = this.props.showUnsetOption || false; const optionsLength = showUnsetOption ? this.props.options.length : this.props.options.length - 1; const nextIndex = currentIndex < optionsLength ? currentIndex + 1 : 0; const nextOption = showUnsetOption && nextIndex > 0 ? this.props.options[nextIndex - 1] : this.props.options[nextIndex]; if (nextOption?.isDisabled === true) { return this.getNextAvailableOption(nextIndex); } return nextIndex; }; getPrevAvailableOption = (currentIndex: number = -1): number => { const showUnsetOption = this.props.showUnsetOption || false; const optionsLength = showUnsetOption ? this.props.options.length : this.props.options.length - 1; const nextIndex = currentIndex > 0 ? currentIndex - 1 : optionsLength; const nextOption = showUnsetOption ? this.props.options[nextIndex - 1] : this.props.options[nextIndex]; if (nextOption?.isDisabled === true) { return this.getPrevAvailableOption(nextIndex); } return nextIndex; }; handleUnset = () => { this.props.onSelect(null); }; isNavigationDisabled = (): boolean => { return this.props.options.every((option) => option.isDisabled); } override componentDidUpdate (prevProps: Readonly, prevState: Readonly, snapshot?: any) { if (prevProps.options !== this.props.options) { this.setState({cursorItemIndex: undefined, isNavigationDisabled: this.isNavigationDisabled()}); } else { if (prevState.cursorItemIndex !== this.state.cursorItemIndex && this.state.cursorItemIndex !== undefined) { if (this.navigatedElement.current && this.containerElement.current) { const element = this.navigatedElement.current; const container = this.containerElement.current; const elementTop = element.offsetTop; const elementBottom = elementTop + element.clientHeight; const containerTop = container.scrollTop; let containerBottom = containerTop; if (this.props.maxHeight === undefined) { containerBottom += container.clientHeight; } else { containerBottom += Math.min(container.clientHeight, this.props.maxHeight); } if (elementTop > containerBottom || elementBottom < containerTop) { element.scrollIntoView({block: 'center'}); } else if (elementTop < containerTop) { element.scrollIntoView({block: 'start'}); } else if (elementBottom > containerBottom) { element.scrollIntoView({block: 'end'}); } } } } } override componentDidMount () { const value = this.props.value; const isOnlyOneValueSelected = value !== undefined && (Array.isArray(value) && value.length === 1 || !Array.isArray(value)); if (isOnlyOneValueSelected) { const selectedValue = Array.isArray(value) ? value[0] : value; const index = this.props.options.findIndex((option) => option.value === selectedValue); if (index > -1) { this.setState({cursorItemIndex: index}); } } this.setState({isNavigationDisabled: this.isNavigationDisabled()}); document.addEventListener('keydown', this.handleKeyDown); } override componentWillUnmount () { document.removeEventListener('keydown', this.handleKeyDown); } handleClick = (option: IOption | null) => (e: React.SyntheticEvent) => { this.props.onSelect(option); }; renderOption = (option: IOption) => { if (this.props.optionRenderer !== undefined) { return this.props.optionRenderer(option); } return ; } override render () { let cursorOptionIndex: number | undefined = undefined; if (this.state.cursorItemIndex !== undefined) { cursorOptionIndex = this.props.showUnsetOption ? this.state.cursorItemIndex - 1 : this.state.cursorItemIndex; } const style = this.props.maxHeight ? {maxHeight: `${this.props.maxHeight}px`} : undefined; const value = this.props.value; return (
{this.props.showUnsetOption && (
--{this.props.unsetText || ''}--
)} {this.props.options.map((option, index) => { const highlighted = value && (value instanceof Array) ? value.includes(option.value) : value === option.value; const hasCursor = index === cursorOptionIndex; const ref = hasCursor ? this.navigatedElement : undefined; const onClick = option.isDisabled ? undefined : this.handleClick(option); const classNames = joinClassNames( styles.optionContainer, [styles.highlighted, highlighted], [styles.hasCursor, hasCursor], [styles.isDisabled, option.isDisabled === true] ); return (
{this.renderOption(option)}
); })}
); } }