@spaced-out/ui-design-system
Version:
Sense UI components library
392 lines (365 loc) • 11.5 kB
Flow
// @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>
);
},
);