@spaced-out/ui-design-system
Version:
Sense UI components library
390 lines (365 loc) • 12.6 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 without from 'lodash/without';
import {useReferenceElementWidth} from '../../hooks';
import {spaceNone, spaceXXSmall} from '../../styles/variables/_space';
import {TEXT_COLORS} from '../../types/typography';
import classify from '../../utils/classify';
import {ClickAway} from '../../utils/click-away';
import type {ClickAwayRefType} from '../../utils/click-away/click-away';
import {mergeRefs} from '../../utils/merge-refs';
import {
getFirstOption,
getFirstOptionFromGroup,
} from '../../utils/token-list-input/token-list-input';
import {ANCHOR_POSITION_TYPE, STRATEGY_TYPE} from '../ButtonDropdown';
import {CircularLoader} from '../CircularLoader';
import {Icon} from '../Icon';
import type {InputProps} from '../Input';
import type {BaseMenuProps} from '../Menu';
import {Menu} from '../Menu';
import {BodySmall} from '../Text';
import {type ElevationType, getElevationValue} from '../Tooltip';
import type {ResolveTokenValueProps} from './TokenValueChips';
import {TokenValueChips} from './TokenValueChips';
import css from './TokenListInput.module.css';
export const DEFAULT_LIMIT_VALUE = 100;
type ClassNames = $ReadOnly<{
wrapper?: string,
box?: string,
input?: string,
}>;
export type TokenListMenuOptionTypes<T> = {
options?: Array<T>,
groupTitleOptions?: Array<TokenGroupTitleOption<T>>,
resolveLabel?: (option: T) => string,
};
export type TokenGroupTitleOption<T> = {
groupTitle?: React.Node,
options?: Array<T>,
showLineDivider?: boolean,
};
export type TokenListMenuProps<T> = {
...BaseMenuProps,
...TokenListMenuOptionTypes<T>,
};
// TODO: use Generics with Constraints when we have typescript.
export type Props<T> = {
classNames?: ClassNames,
clickAwayRef?: ClickAwayRefType,
disabled?: boolean, // disables user interaction with the input
error?: boolean,
errorText?: string,
focusOnMount?: boolean,
helperText?: string,
inputValue?: string,
inputPlaceholder?: string,
limit?: number, // maximum number of values
isLoading?: boolean,
locked?: boolean,
onChange: (values: Array<T>) => mixed, // an onChange handler
onInputBlur?: (e: SyntheticEvent<HTMLInputElement>) => mixed,
onInputChange?: (value: string) => mixed,
onInputFocus?: (e: SyntheticEvent<HTMLInputElement>) => mixed,
placeholder?: string,
size?: 'medium' | 'small',
tabIndex?: number,
values: Array<T>, // a list of options representing the current value of the input
menu?: TokenListMenuProps<T>,
onMenuOpen?: () => mixed,
onMenuClose?: () => mixed,
resolveTokenValue?: (ResolveTokenValueProps<T>) => React.Node,
inputProps?: InputProps,
elevation?: ElevationType,
};
export function TokenListInput<T>(props: Props<T>): React.Node {
const {
classNames,
clickAwayRef,
disabled = false,
error,
errorText,
focusOnMount,
helperText,
inputValue = '',
inputPlaceholder = '',
limit = DEFAULT_LIMIT_VALUE,
isLoading,
locked,
onChange,
menu,
onMenuOpen,
onMenuClose,
onInputBlur,
onInputChange,
onInputFocus,
placeholder,
size = 'medium',
tabIndex,
values,
resolveTokenValue,
inputProps,
elevation = 'modal',
} = props;
const menuRef = React.useRef<HTMLDivElement | null>(null);
const {x, y, refs, strategy, context} = useFloating({
open: true,
strategy: STRATEGY_TYPE.absolute,
placement: ANCHOR_POSITION_TYPE.bottomStart,
whileElementsMounted: autoUpdate,
middleware: [flip(), offset(parseInt(spaceXXSmall))],
});
const inputRef = React.useRef<?HTMLInputElement>();
const dropdownWidth = useReferenceElementWidth(refs.reference?.current);
const onOpenToggle = (isOpen) => {
if (isOpen) {
onMenuOpen?.();
inputRef.current?.focus();
} else {
onMenuClose?.();
inputRef.current?.blur();
}
};
React.useEffect(() => {
if (focusOnMount) {
inputRef.current?.focus();
}
}, [focusOnMount]);
const addValue = (value: T) => {
if (locked || !value || value.disabled) {
return; // Prevent adding values when disabled or locked
}
onInputChange?.('');
// $FlowFixMe[incompatible-use] - token has key property
const existingToken = values.find((token) => token.key === value.key);
if (!existingToken) {
onChange([...values, value]);
}
setTimeout(() => {
inputRef.current && inputRef.current.focus();
}, 0);
};
const removeValue = (value: T) => {
!disabled && !locked && onChange(without(values, value));
setTimeout(() => {
inputRef.current && inputRef.current.focus();
}, 0);
};
const hideInput = values.length >= limit || disabled || locked;
const handleInputKeyDown = (
event: SyntheticKeyboardEvent<HTMLInputElement>,
) => {
const value = event.currentTarget.value;
const key = event.key;
// Note: adding this Enter key handler to handle the case where the user is typing a new value
// and presses Enter to add the value to the list (maintain parity with the old TokenListInput)
if (key === 'Enter') {
if (value.trim()) {
event.preventDefault();
let firstOption = null;
if (menu?.options && menu.options.length > 0) {
//$FlowFixMe
firstOption = getFirstOption(menu.options);
} else if (
menu?.groupTitleOptions &&
menu.groupTitleOptions.length > 0
) {
//$FlowFixMe
firstOption = getFirstOptionFromGroup(menu.groupTitleOptions);
}
if (firstOption) {
//$FlowFixMe
addValue(firstOption);
}
}
} else if (key === 'Backspace') {
if (inputValue === '' && values.length > 0) {
onChange(values.slice(0, -1));
}
} else if (key === 'Escape') {
event.preventDefault();
setTimeout(() => {
inputRef.current && inputRef.current.blur();
}, 0);
}
};
return (
<ClickAway
closeOnEscapeKeypress={true}
onChange={onOpenToggle}
clickAwayRef={clickAwayRef}
>
{({isOpen, onOpen, clickAway, boundaryRef, triggerRef}) => (
<>
<div
ref={menuRef}
className={classify(css.tokenListContainer, classNames?.wrapper)}
data-testid="TokenListInput"
>
<div
onClick={() => {
if (disabled || locked || values.length >= limit) {
return;
}
if (!isOpen) {
onOpen();
}
}}
onFocus={(e) => {
if (disabled || locked || values.length >= limit) {
return;
}
if (!isOpen) {
onOpen();
}
onInputFocus?.(e);
}}
onBlur={(e) => {
onInputBlur?.(e);
}}
className={classify(
css.box,
{
[css.inputDisabled]: disabled,
[css.medium]: size === 'medium',
[css.small]: size === 'small',
[css.withError]: error,
[css.inputLocked]: locked,
},
classNames?.box,
)}
ref={mergeRefs([refs.setReference, triggerRef])}
>
<TokenValueChips
values={values}
resolveTokenValue={resolveTokenValue}
disabled={disabled}
locked={locked}
onDismiss={removeValue}
/>
{!hideInput && (
<input
{...inputProps}
ref={inputRef}
type="text"
readOnly={locked}
value={inputValue}
placeholder={
values.length === 0
? placeholder || inputPlaceholder
: inputPlaceholder
}
onChange={(event) => {
onInputChange?.(event.target.value);
!isOpen && onOpen();
}}
disabled={disabled || locked}
tabIndex={tabIndex}
data-qa-id="token-list-input"
onKeyDown={handleInputKeyDown}
className={classify(
{
[css.inputMedium]: size === 'medium',
[css.inputSmall]: size === 'small',
},
classNames?.input,
)}
autoComplete="off"
/>
)}
{isLoading && (
<div className={css.loaderContainer}>
<CircularLoader size="small" colorToken="colorFillPrimary" />
</div>
)}
{locked && (
<Icon
name="lock"
color={disabled ? 'disabled' : 'secondary'}
size="small"
className={css.lockIcon}
/>
)}
</div>
{!isOpen && (Boolean(helperText) || errorText) && (
<div className={css.footerTextContainer}>
{error && errorText ? (
<BodySmall color={TEXT_COLORS.danger}>{errorText}</BodySmall>
) : typeof helperText === 'string' ? (
<BodySmall color={TEXT_COLORS.secondary}>
{helperText}
</BodySmall>
) : (
helperText
)}
</div>
)}
{!locked && isOpen && menu && (
<FloatingPortal>
<FloatingFocusManager
modal={false}
context={context}
returnFocus={false}
initialFocus={refs.reference}
>
<div
className={css.menuWrapper}
ref={mergeRefs([refs.setFloating, boundaryRef])}
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),
}}
>
{/* $FlowFixMe[incompatible-type] Menu expects MenuOption but receives T */}
{/* $FlowFixMe[prop-missing] MenuOption properties are missing in T */}
<Menu
{...menu}
onSelect={(option) => {
if (values.length >= limit) {
return;
}
// $FlowFixMe[incompatible-call] option from Menu is MenuOption but addValue expects T
// $FlowFixMe[prop-missing] MenuOption properties are missing in T
addValue(option);
clickAway();
inputRef.current?.focus();
}}
size={menu.size || size}
onTabOut={clickAway}
ref={menuRef}
/>
</div>
</FloatingFocusManager>
</FloatingPortal>
)}
</div>
</>
)}
</ClickAway>
);
}
TokenListInput.displayName = 'TokenListInput';