UNPKG

@spaced-out/ui-design-system

Version:
390 lines (365 loc) 12.6 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 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';