import * as React from 'react'; import classnames from 'classnames'; import CheckSVG from '../../../svg/checkmark--big.svg'; import DownloadSmallSVG from '../../../svg/download--small.svg'; import ArrowRightSVG from '../../../svg/arrow--right.svg'; import IReactComponentProps from '../../../common/structures/IReactComponentProps'; import styles from './FlySelect.scss'; import { FunctionGeneric } from '../../../common/structures/Generics'; import { CaretIcon } from '../../icons/Icons' export interface FlySelectOption { disabled?: boolean; download?: boolean; icon?: React.ReactNode; label: React.ReactNode; metadata?: any; optionGroup?: FlySelectOptionGroup | null; secondaryText?: React.ReactNode; value?: string; } export interface FlySelectOptionGroup { label: React.ReactNode; linkText?: string; linkOnClick?: FunctionGeneric; } export type FlySelectOptionGroups = {[key: string]: FlySelectOptionGroup}; export type FlySelectOptions = string[] | FlySelectOptionsFormatted | {[value: string]: string}; type FlySelectOptionFormatted = FlySelectOption; type FlySelectOptionsFormatted = {[value: string]: FlySelectOptionFormatted}; interface IProps extends IReactComponentProps { disabled?: boolean; emptyPlaceholder?: string; footerText?: string; footerOnClick?: FunctionGeneric; loadingOptionsPlaceholder?: string; onChange: FunctionGeneric; options?: FlySelectOptions; optionsLoader?: Promise | any; optionGroups?: FlySelectOptionGroups; placeholder?: string; readonly?: boolean; striped?: boolean; value?: string; } interface IState { focus: boolean; focusedIndex: number; optionsFormatted: FlySelectOptionsFormatted; open: boolean; optionsLoaded: boolean | null; value: any; willClose: boolean; } export default class FlySelect extends React.Component { private readonly __containerRef: React.RefObject; private readonly __optionsRef: React.RefObject; constructor (props: IProps) { super(props); this.state = { focus: false, focusedIndex: 0, open: false, optionsFormatted: this.props.options ? this.formatOptions(this.props.options) : {}, optionsLoaded: this.props.optionsLoader ? false : null, value: this.props.value, willClose: false, }; this.onClick = this.onClick.bind(this); this.onBlur = this.onBlur.bind(this); this.selectOption = this.selectOption.bind(this); this.renderOption = this.renderOption.bind(this); this.calculateOptionsPosition = this.calculateOptionsPosition.bind(this); this.onContainerKeyDown = this.onContainerKeyDown.bind(this); this.onOptionKeyDown = this.onOptionKeyDown.bind(this); this.__containerRef = React.createRef(); this.__optionsRef = React.createRef(); } componentDidMount () { if (typeof this.props.optionsLoader === 'function') { this.props.optionsLoader().then((options: IProps['options']) => this.setState({ optionsFormatted: this.formatOptions(options), optionsLoaded: true, })); } } componentDidUpdate (previousProps: IProps) { if (previousProps.value !== this.props.value) { this.setState({ value: this.props.value, }); } if (previousProps.options !== this.props.options) { this.setState({ optionsFormatted: this.formatOptions(this.props.options), }); } } formatOptions (options: any): FlySelectOptionsFormatted { const formattedOptions: {[key: string]: any} = {}; const formatOption = (option: FlySelectOptionFormatted, value: any = null) => { if (typeof option === 'object') { if (typeof option.value === 'undefined' && value !== null) { option.value = value; } if (typeof option.optionGroup === 'undefined') { option.optionGroup = null; } formattedOptions[option.value as string] = option; return; } formattedOptions[value !== null ? value : option] = { label: option, optionGroup: null, value: value !== null ? value : option, }; }; if (Array.isArray(options)) { options.forEach((option) => formatOption(option)); } else { Object.keys(options).forEach((optionValue) => formatOption(options[optionValue], optionValue)); } return formattedOptions; } onClick () { this.setState({ focus: true, open: true, }); } onBlur (event: any) { if (!event.currentTarget.contains(event.relatedTarget)){ this.setState({ focus: false, open: false, }); } } selectOption (e: any, value: any) { this.setState({ open: false, value, }); this.props.onChange.call(this, value); e.stopPropagation(); } calculateOptionsPosition () { if (!this.state.open) { return undefined; } const optionsBounding = this.__containerRef.current.getBoundingClientRect(); const maxBottomBounding = window.innerHeight - 40; return { left: optionsBounding.left, maxHeight: maxBottomBounding - optionsBounding.top, minWidth: optionsBounding.right - optionsBounding.left, top: optionsBounding.top, }; } onContainerKeyDown (event: any) { let open = this.state.open; let focusedIndex = this.state.focusedIndex; switch (event.key) { case ' ': open = true; break; case 'Enter': open = true; break; case 'ArrowUp': open = true; if (focusedIndex > 0) { focusedIndex--; } break; case 'ArrowDown': open = true; if (focusedIndex < Object.keys(this.state.optionsFormatted).length - 1){ focusedIndex++; } break; case 'Tab': open = false; focusedIndex = 0; break; } this.setState({ focusedIndex, open, }, () => { if (this.state.open){ this.__optionsRef.current.children[this.state.focusedIndex] && this.__optionsRef.current.children[this.state.focusedIndex].focus(); } }); } onOptionKeyDown = (e: any, value: any) => { if (e.key === 'Enter' || e.key === ' '){ this.selectOption(e, value); } this.__containerRef.current.focus(); } renderPlaceholder () { if (this.state.optionsLoaded === false) { return this.props.loadingOptionsPlaceholder; } if (Object.keys(this.state.optionsFormatted).length) { return this.props.placeholder; } return this.props.emptyPlaceholder; } renderItem (option: FlySelectOptionFormatted, showCheck: boolean = false) { const output = []; if (option.download === true) { output.push( , ); } if (option.icon) { if (typeof option.icon === 'string') { output.push( , ); } else { output.push(React.cloneElement(option.icon as React.ReactElement, { key: 'icon', className: 'FlySelect__ItemIcon' })); } } output.push( {option.label} , ); output.push(this.renderItemRight(option, showCheck)); return output; } renderItemRight (option: FlySelectOptionFormatted, showCheck: boolean) { return ( { 'secondaryText' in option && option.secondaryText && {option.secondaryText} } { showCheck && option.value === this.state.value && } ); } renderFooter () { if (!this.props.footerText) { return ''; } return ( ); } renderOption (optionValue: FlySelectOption['value'], i: number, group: any) { const optionsFormatted = this.state.optionsFormatted; const option = optionsFormatted[optionValue as string]; const disabled = typeof optionsFormatted[optionValue as string] === 'object' ? optionsFormatted[optionValue as string].disabled : false; if (option.optionGroup !== group) { return null; } return (
this.onOptionKeyDown(e, optionValue)} onClick={(e) => this.selectOption(e, optionValue)} > {this.renderItem(option, true)}
); } renderOptionGroups () { if (!this.props.optionGroups) { return null; } const output: any[] = []; const optionsFormatted = this.state.optionsFormatted; Object.keys(this.props.optionGroups).forEach((optionGroupID) => { const optionGroup = this.props.optionGroups && this.props.optionGroups[optionGroupID]; const optionNodes = Object.keys(optionsFormatted) .map((optionValue: any, i: number) => this.renderOption(optionValue, i, optionGroupID)) .filter((n) => n) ; if (!optionNodes.length || !optionGroup) { return; } output.push( <>
{optionGroup.label} { optionGroup.linkText ? {optionGroup.linkText} : null }
{/* note: this is here to ensure that the expected alternating row color order is maintained */} , ); output.push(optionNodes); }); return output; } render () { const optionsFormatted = this.state.optionsFormatted; const Tag: any = 'div'; return ( { this.state.value in optionsFormatted ? this.renderItem(optionsFormatted[this.state.value]) : {this.renderPlaceholder()} }
{Object.keys(optionsFormatted).map((optionValue, i) => this.renderOption(optionValue, i, null))} {this.renderOptionGroups()}
{this.renderFooter()}
); } }