@spaced-out/ui-design-system
Version:
Sense UI components library
251 lines (233 loc) • 8.02 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]
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} />;
}
},
);