UNPKG

@spaced-out/ui-design-system

Version:
251 lines (233 loc) 8.02 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] 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 {InputProps} from '../Input'; import type {MenuOption, MenuProps} from '../Menu'; import {Menu} from '../Menu'; import {SearchInput} from '../SearchInput'; import type {ElevationType} from '../Tooltip'; import {getElevationValue} from '../Tooltip'; import css from './Typeahead.module.css'; type ClassNames = $ReadOnly<{wrapper?: string, box?: string}>; type BaseTypeaheadProps = { ...InputProps, classNames?: ClassNames, onSelect?: (option: MenuOption, ?SyntheticEvent<HTMLElement>) => mixed, onSearch?: (evt: SyntheticInputEvent<HTMLInputElement>) => mixed, onMenuOpen?: () => mixed, onMenuClose?: () => mixed, typeaheadInputText?: string, menu?: MenuProps, onClear?: () => void, isLoading?: boolean, menuOpenOffset?: number, clickAwayRef?: ClickAwayRefType, elevation?: ElevationType, ... }; export type TypeaheadProps = { ...BaseTypeaheadProps, allowInternalFilter?: boolean, ... }; const BaseTypeahead: React$AbstractComponent< BaseTypeaheadProps, HTMLInputElement, > = React.forwardRef<BaseTypeaheadProps, HTMLInputElement>( ( { size = 'medium', classNames, placeholder = 'Select...', onSelect, onSearch, onClear, menu, onMenuOpen, onMenuClose, typeaheadInputText = '', isLoading, menuOpenOffset = 1, onFocus, clickAwayRef, elevation = 'modal', ...inputProps }: BaseTypeaheadProps, ref, ): React.Node => { const menuOptions = menu?.options; const {x, y, refs, strategy, context} = useFloating({ open: true, strategy: 'absolute', placement: 'bottom-start', whileElementsMounted: autoUpdate, middleware: [flip(), offset(parseInt(spaceXXSmall))], }); const dropdownWidth = useReferenceElementWidth(refs.reference?.current); const onMenuToggle = (isOpen: boolean) => { isOpen ? onMenuOpen && onMenuOpen() : onMenuClose && onMenuClose(); }; return ( <ClickAway onChange={onMenuToggle} clickAwayRef={clickAwayRef}> {({isOpen, onOpen, clickAway, boundaryRef, triggerRef}) => ( <div data-testid="Typeahead" className={classify(css.typeaheadContainer, classNames?.wrapper)} > <SearchInput {...inputProps} ref={ref} boxRef={mergeRefs([refs.setReference, triggerRef])} size={size} placeholder={placeholder} value={typeaheadInputText} classNames={{box: classNames?.box}} isLoading={isLoading} onChange={(e) => { e.stopPropagation(); onSearch && onSearch(e); if (e.target.value.length >= menuOpenOffset) { !isOpen && onOpen(); } else { clickAway(); } }} onFocus={(_e) => { if (typeaheadInputText.length >= menuOpenOffset) { !isOpen && onOpen(); } else { clickAway(); } onFocus?.(_e); }} onClear={(_e) => { onClear?.(); }} /> {isOpen && !isLoading && menu && menuOptions && !!menuOptions.length && ( <FloatingPortal> <FloatingFocusManager modal={false} context={context} returnFocus={false} initialFocus={refs.reference} > <div ref={mergeRefs([refs.setFloating, boundaryRef])} className={css.menuWrapper} style={{ 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} options={menuOptions} onSelect={(option, e) => { onSelect && onSelect(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> ); }, ); const StatefulTypeahead: React$AbstractComponent< BaseTypeaheadProps, HTMLInputElement, > = React.forwardRef<BaseTypeaheadProps, HTMLInputElement>( ({menu, ...props}: BaseTypeaheadProps, ref): React.Node => { const {typeaheadInputText = ''} = props; const [filteredOptions, setFilteredOptions] = React.useState(menu?.options); React.useEffect(() => { const optionsFiltered = menu?.options && menu.options.filter((option) => { if (!option.label || !typeaheadInputText) { return true; } else { return ( option.label .toLowerCase() .indexOf(typeaheadInputText.toLowerCase()) !== -1 ); } }); setFilteredOptions(optionsFiltered || []); }, [typeaheadInputText, menu?.options]); return ( <BaseTypeahead {...props} menu={{...menu, options: filteredOptions}} ref={ref} /> ); }, ); const StatelessTypeahead: React$AbstractComponent< BaseTypeaheadProps, HTMLInputElement, > = React.forwardRef<BaseTypeaheadProps, HTMLInputElement>( (props: BaseTypeaheadProps, ref): React.Node => ( <BaseTypeahead {...props} ref={ref} /> ), ); export const Typeahead: React$AbstractComponent< TypeaheadProps, HTMLInputElement, > = React.forwardRef<TypeaheadProps, HTMLInputElement>( ({allowInternalFilter = true, ...props}: TypeaheadProps, ref): React.Node => { if (allowInternalFilter) { return <StatefulTypeahead {...props} ref={ref} />; } else { return <StatelessTypeahead {...props} ref={ref} />; } }, );