UNPKG

@spaced-out/ui-design-system

Version:
392 lines (365 loc) 11.5 kB
// @flow strict import * as React from 'react'; // $FlowIssue[nonstrict-import] react-window import {FixedSizeList} from 'react-window'; import type {MenuClassNames, MenuLabelTooltip} from '../../types/menu'; import {classify} from '../../utils/classify'; import { getFilteredComposeOptionsFromSearchText, getFilteredComposeOptionsResultText, getFilteredGroupTitleOptionsFromSearchText, getFilteredGroupTitleOptionsResultText, getFilteredOptionsFromSearchText, getFilteredOptionsResultText, } from '../../utils/menu'; import type {IconType} from '../Icon/Icon'; import {SearchInput} from '../SearchInput'; import {FormLabelSmall} from '../Text'; import {MenuOptionButton} from './MenuOptionButton'; import css from './Menu.module.css'; type ClassNames = MenuClassNames; type OptionClassNames = $ReadOnly<{ wrapper?: string, }>; export type Virtualization = $ReadOnly<{ enable: boolean, itemHeight?: number, menuHeight?: number, }>; export type MenuOption = { key: string, classNames?: OptionClassNames, label?: string, secondaryLabel?: string, customComponent?: React.Node, iconLeft?: string, iconLeftType?: IconType, iconRight?: string, iconRightType?: IconType, disabled?: boolean, optionSize?: MenuSizeTypes, optionVariant?: MenuOptionsVariant, keepMenuOpenOnOptionSelect?: boolean, indeterminate?: boolean, }; // Render first available option set export type BaseMenuProps = { onSelect?: (option: MenuOption, ?SyntheticEvent<HTMLElement>) => mixed, selectedOption?: ?MenuOption, optionsVariant?: MenuOptionsVariant, selectedKeys?: Array<string>, classNames?: ClassNames, size?: MenuSizeTypes, width?: string, menuDisabled?: boolean, isFluid?: boolean, // onTabOut is a callback function that is called when // the user navigates outside of the menu using the tab key. onTabOut?: () => mixed, allowSearch?: boolean, // A function that resolves the label for a MenuOption. // It takes a MenuOption as a parameter and returns either a string or a React Node. resolveLabel?: (option: MenuOption) => string | React.Node, // A function that resolves the secondaryLabel for a MenuOption. // It takes a MenuOption as a parameter and returns either a string or a React Node. resolveSecondaryLabel?: (option: MenuOption) => string | React.Node, // When virtualization is enabled, the MenuOptionButtons will be rendered only when they are present in the Menu's viewport virtualization?: Virtualization, header?: React.Node, footer?: React.Node, showResultText?: boolean, staticLabels?: { RESULT?: string, RESULTS?: string, SEARCH_PLACEHOLDER?: string, }, showLabelTooltip?: MenuLabelTooltip, allowWrap?: boolean, }; export type MenuOptionTypes = { options?: Array<MenuOption>, composeOptions?: Array<Array<MenuOption>>, groupTitleOptions?: Array<MenuGroupTitleOption>, }; export type MenuSizeTypes = 'medium' | 'small'; export type MenuOptionsVariant = 'checkbox' | 'radio' | 'normal'; export type MenuGroupTitleOption = { groupTitle?: React.Node, options?: Array<MenuOption>, showLineDivider?: boolean, }; export type MenuProps = { ...BaseMenuProps, ...MenuOptionTypes, }; export type RenderOptionProps = { ...MenuProps, searchText?: string, }; const menuSizeMedium = 276, menuSizeSmall = 228; const buttonSizeMedium = 40, buttonSizeSmall = 32; const RenderOption = ({ options, composeOptions, groupTitleOptions, classNames, searchText = '', showResultText = true, staticLabels = { RESULT: 'result', RESULTS: 'results', SEARCH_PLACEHOLDER: 'Search...', }, ...restProps }: RenderOptionProps): React.Node => { const { allowSearch, size, virtualization = { enable: false, menuHeight: null, itemHeight: null, }, } = restProps; if (options && Array.isArray(options) && options.length) { const optionsFiltered = !allowSearch ? options : getFilteredOptionsFromSearchText(options, searchText); const finalResultText = !allowSearch ? '' : getFilteredOptionsResultText(optionsFiltered, staticLabels); const { enable: isVirtualizationEnabled, menuHeight, itemHeight, } = virtualization; return ( <> {allowSearch && showResultText && ( <FormLabelSmall className={css.filterOptionsResultText} color="tertiary" > {finalResultText} </FormLabelSmall> )} {virtualization && isVirtualizationEnabled ? ( <FixedSizeList height={ menuHeight || (size === 'medium' ? menuSizeMedium : menuSizeSmall) } itemSize={ itemHeight || (size === 'medium' ? buttonSizeMedium : buttonSizeSmall) } itemCount={optionsFiltered.length} > {({index: idx, style}) => { const buttonOption = optionsFiltered[idx]; return ( <React.Fragment key={buttonOption.key}> <MenuOptionButton option={buttonOption} classNames={classNames} style={style} {...restProps} isLastItem={idx === optionsFiltered.length - 1} /> </React.Fragment> ); }} </FixedSizeList> ) : ( optionsFiltered.map((option, idx) => ( <React.Fragment key={option.key}> <MenuOptionButton option={option} classNames={classNames} {...restProps} isLastItem={idx === optionsFiltered.length - 1} /> </React.Fragment> )) )} </> ); } if ( composeOptions && Array.isArray(composeOptions) && composeOptions.length ) { const optionsFiltered = !allowSearch ? composeOptions : getFilteredComposeOptionsFromSearchText(composeOptions, searchText); const finalResultText = !allowSearch ? '' : getFilteredComposeOptionsResultText(optionsFiltered, staticLabels); return ( <> {allowSearch && showResultText && ( <FormLabelSmall className={css.filterOptionsResultText} color="tertiary" > {finalResultText} </FormLabelSmall> )} {optionsFiltered.map((composeMenuOptions, index) => ( // eslint-disable-next-line react/no-array-index-key <span key={index} className={css.menuDivider}> {composeMenuOptions.map((option, idx) => ( <React.Fragment key={option.key}> <MenuOptionButton option={option} classNames={classNames} {...restProps} isLastItem={ index === optionsFiltered.length - 1 && idx === composeMenuOptions.length - 1 } /> </React.Fragment> ))} </span> ))} </> ); } if ( groupTitleOptions && Array.isArray(groupTitleOptions) && groupTitleOptions.length ) { const optionsFiltered = !allowSearch ? groupTitleOptions : getFilteredGroupTitleOptionsFromSearchText( groupTitleOptions, searchText, ); const finalResultText = !allowSearch ? '' : getFilteredGroupTitleOptionsResultText(optionsFiltered, staticLabels); return ( <> {allowSearch && showResultText && ( <FormLabelSmall className={css.filterOptionsResultText} color="tertiary" > {finalResultText} </FormLabelSmall> )} {optionsFiltered.map((optionsGroup, index) => ( // eslint-disable-next-line react/no-array-index-key <React.Fragment key={index}> {!!optionsGroup.groupTitle && ( <div className={classify( css.groupTitleWrapper, classNames?.groupTitle, )} > {optionsGroup.groupTitle} </div> )} {optionsGroup.options?.map((option, idx) => ( <React.Fragment key={option.key}> <MenuOptionButton option={option} classNames={classNames} {...restProps} isLastItem={ index === optionsFiltered.length - 1 && idx === (optionsGroup.options && optionsGroup.options.length - 1) } /> </React.Fragment> ))} </React.Fragment> ))} </> ); } return <></>; }; export const Menu: React$AbstractComponent<MenuProps, HTMLDivElement> = React.forwardRef<MenuProps, HTMLDivElement>( (props: MenuProps, ref): React.Node => { const { classNames, size = 'medium', width, isFluid = true, allowSearch, virtualization = { enable: false, menuHeight: null, itemHeight: null, }, header, footer, staticLabels, } = props; const [searchText, setSearchText] = React.useState(''); const {menuHeight} = virtualization; const hasHeader = header ? true : false; const hasFooter = footer ? true : false; return ( <div className={classify( css.menuCard, { [css.fluid]: isFluid, [css.medium]: size === 'medium', [css.mediumWithHeader]: size === 'medium' && hasHeader && !hasFooter, [css.mediumWithFooter]: size === 'medium' && !hasHeader && hasFooter, [css.mediumWithHeaderAndFooter]: size === 'medium' && hasFooter && hasHeader, [css.small]: size === 'small', [css.smallWithHeader]: size === 'small' && hasHeader && !hasFooter, [css.smallWithFooter]: size === 'small' && !hasHeader && hasFooter, [css.smallWithHeaderAndFooter]: size === 'small' && hasFooter && hasHeader, [css.menuCardTopPaddingZero]: header, [css.menuCardBottomPaddingZero]: footer, }, classNames?.wrapper, )} style={{ width, maxHeight: menuHeight ? menuHeight + 'px' : '', }} ref={ref} > {hasHeader && ( <div className={classify(css.menuHeader, classNames?.header)}> {header} </div> )} {allowSearch && ( <SearchInput value={searchText} onChange={(e) => setSearchText(e.target.value)} onClear={() => setSearchText('')} size={size} placeholder={staticLabels?.SEARCH_PLACEHOLDER ?? 'Search...'} /> )} <RenderOption {...props} searchText={searchText} /> {hasFooter && ( <div className={classify(css.menuFooter, classNames?.footer)}> {footer} </div> )} </div> ); }, );