UNPKG

@spaced-out/ui-design-system

Version:
227 lines (211 loc) 7.21 kB
// @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> ); }, );