@spaced-out/ui-design-system
Version:
Sense UI components library
227 lines (211 loc) • 7.21 kB
Flow
// @flow strict
import * as React from 'react';
import {
// $FlowFixMe[untyped-import]
autoUpdate,
// $FlowFixMe[untyped-import]
flip,
// $FlowFixMe[untyped-import]
FloatingFocusManager,
// $FlowFixMe[untyped-import]
FloatingPortal,
// $FlowFixMe[untyped-import]
offset,
// $FlowFixMe[untyped-import]
shift,
// $FlowFixMe[untyped-import]
useFloating,
} from '@floating-ui/react';
import {useReferenceElementWidth} from '../../hooks';
import {spaceNone, spaceXXSmall} from '../../styles/variables/_space';
import {classify} from '../../utils/classify';
import {type ClickAwayRefType, ClickAway} from '../../utils/click-away';
import {mergeRefs} from '../../utils/merge-refs';
import type {ButtonProps} from '../Button';
import {Button} from '../Button';
import {ConditionalWrapper} from '../ConditionalWrapper';
import type {MenuOption, MenuProps} from '../Menu';
import {Menu} from '../Menu';
import type {BaseTooltipProps, ElevationType} from '../Tooltip';
import {getElevationValue, Tooltip} from '../Tooltip';
import css from './ButtonDropdown.module.css';
export const ANCHOR_POSITION_TYPE = Object.freeze({
top: 'top',
topStart: 'top-start',
topEnd: 'top-end',
bottom: 'bottom',
bottomStart: 'bottom-start',
bottomEnd: 'bottom-end',
});
export const STRATEGY_TYPE = Object.freeze({
absolute: 'absolute',
fixed: 'fixed',
});
export type AnchorType = $Values<typeof ANCHOR_POSITION_TYPE>;
export type Strategy = $Values<typeof STRATEGY_TYPE>;
type ClassNames = $ReadOnly<{
buttonWrapper?: string,
dropdownContainer?: string,
buttonIcon?: string,
}>;
export type ButtonDropdownProps = {
...ButtonProps,
classNames?: ClassNames,
menu?: MenuProps,
positionStrategy?: Strategy,
anchorPosition?: AnchorType,
onOptionSelect?: (option: MenuOption, ?SyntheticEvent<HTMLElement>) => mixed,
onMenuOpen?: () => mixed,
onMenuClose?: () => mixed,
tooltip?: BaseTooltipProps,
elevation?: ElevationType,
clickAwayRef?: ClickAwayRefType,
...
};
export const ButtonDropdown: React$AbstractComponent<
ButtonDropdownProps,
HTMLDivElement,
> = React.forwardRef<ButtonDropdownProps, HTMLDivElement>(
(
{
anchorPosition = 'bottom-start',
positionStrategy = STRATEGY_TYPE.absolute,
size = 'medium',
onOptionSelect,
menu,
classNames,
disabled,
onMenuOpen,
onMenuClose,
children,
iconRightName,
iconRightType = 'solid',
isFluid,
tooltip,
onClick,
elevation = 'modal',
clickAwayRef,
...restButtonProps
}: ButtonDropdownProps,
forwardRef,
) => {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const {x, y, refs, strategy, context} = useFloating({
open: true,
strategy: positionStrategy,
placement: anchorPosition,
whileElementsMounted: autoUpdate,
middleware: [shift(), flip(), offset(parseInt(spaceXXSmall))],
});
const dropdownWidth = useReferenceElementWidth(refs.reference?.current);
const onMenuToggle = (isOpen: boolean) => {
if (isOpen) {
onMenuOpen?.();
setIsMenuOpen(true);
} else {
onMenuClose?.();
setIsMenuOpen(false);
}
};
return (
<ClickAway onChange={onMenuToggle} clickAwayRef={clickAwayRef}>
{({isOpen, onOpen, clickAway, boundaryRef, triggerRef}) => (
<div
data-testid="ButtonDropdown"
className={classify(
css.buttonDropdownContainer,
{
[css.isFluid]: isFluid === true,
},
classNames?.dropdownContainer,
)}
ref={forwardRef}
>
<ConditionalWrapper
condition={Boolean(tooltip)}
wrapper={(children) => (
<Tooltip
{...tooltip}
hidden={isMenuOpen ? true : tooltip?.hidden}
>
{children}
</Tooltip>
)}
>
<Button
{...restButtonProps}
iconRightName={
children
? iconRightName || (isOpen ? 'caret-up' : 'caret-down')
: iconRightName
}
iconRightType={iconRightType}
disabled={disabled}
size={size}
ref={mergeRefs([refs.setReference, triggerRef])}
onClick={(e) => {
onClick?.(e);
e.stopPropagation();
onOpen();
}}
isFluid={isFluid}
classNames={{
wrapper: classNames?.buttonWrapper,
icon: classNames?.buttonIcon,
}}
>
{children}
</Button>
</ConditionalWrapper>
{isOpen && menu && (
<FloatingPortal>
<FloatingFocusManager
modal={false}
context={context}
initialFocus={refs.reference}
>
<div
className={css.menuWrapper}
ref={mergeRefs([refs.setFloating, boundaryRef])}
style={{
display: 'flex',
position: strategy,
top: y ?? spaceNone,
left: x ?? spaceNone,
/* NOTE(Sharad): The FloatingPortal renders the menu outside the normal DOM structure,
so its parent is effectively the <body> element. This means the menu
would otherwise default to the body's width. To support fluid width,
we must manually set the dropdown width here; otherwise, it uses a fixed width.
Also, Only treat menu as non-fluid if isFluid is strictly false, since default is true in menu and undefined means fluid. */
...(menu.isFluid !== false && {
'--dropdown-width': dropdownWidth,
}),
'--menu-elevation': getElevationValue(elevation),
}}
>
<Menu
{...menu}
onSelect={(option, e) => {
onOptionSelect && onOptionSelect(option, e);
if (
// option.keepMenuOpenOnOptionSelect - to allow the menu persist its open stat upon option selection in normal variant
!option.keepMenuOpenOnOptionSelect &&
(!menu.optionsVariant ||
menu.optionsVariant === 'normal')
) {
clickAway();
}
}}
size={menu.size || size}
onTabOut={clickAway}
/>
</div>
</FloatingFocusManager>
</FloatingPortal>
)}
</div>
)}
</ClickAway>
);
},
);