import { isEmpty, isNumber, isNull } from '@transferwise/neptune-validation'; import { Flag } from '@wise/art'; import { clsx } from 'clsx'; import { Component } from 'react'; import { injectIntl, WrappedComponentProps } from 'react-intl'; import { Typography, Size, SizeLarge, SizeMedium, SizeSmall, getLocaleCurrencyName, } from '../common'; import { withInputAttributes, WithInputAttributesProps } from '../inputs/contexts'; import { Input } from '../inputs/Input'; import { SelectInput, SelectInputItem, SelectInputOptionContent, SelectInputOptionItem, SelectInputProps, } from '../inputs/SelectInput'; import Title from '../title'; import messages from './MoneyInput.messages'; import { formatAmount, formatNumber, getCurrencyDecimals, parseAmount } from './currencyFormatting'; import withId from '../withId'; export interface CurrencyOptionItem { header?: never; value: string; label: string; currency: string; note?: string; searchable?: string; } export interface CurrencyHeaderItem { header: string; } export type CurrencyItem = CurrencyOptionItem | CurrencyHeaderItem; const isNumberOrNull = (v: unknown): v is number | null => isNumber(v) || isNull(v); const formatAmountIfSet = ({ amount, currency, locale, decimals, }: { amount: number | null | undefined; currency: string; locale: string; decimals?: number; }) => { if (typeof amount !== 'number') { return ''; } if (decimals != null && getCurrencyDecimals(currency) !== 0) { return formatNumber(amount, locale, decimals); } return formatAmount(amount, currency, locale); }; const parseNumber = ({ amount, currency, locale, decimals, }: { amount: string; currency: string; locale: string; decimals?: number; }) => { return parseAmount(amount, currency, locale, decimals); }; const allowedInputKeys = new Set([ 'Backspace', 'Delete', ',', '.', 'ArrowDown', 'ArrowUp', 'ArrowLeft', 'ArrowRight', 'Enter', 'Escape', 'Tab', ]); export interface MoneyInputProps extends WrappedComponentProps { id?: string; 'aria-labelledby'?: string; currencies: readonly CurrencyItem[]; selectedCurrency: CurrencyOptionItem; onCurrencyChange?: (value: CurrencyOptionItem) => void; placeholder?: number; amount: number | null; size?: SizeSmall | SizeMedium | SizeLarge; onAmountChange?: (value: number | null) => void; addon?: React.ReactNode; searchPlaceholder?: string; /** * Allows the consumer to react to searching, while the search itself is handled internally. */ onSearchChange?: (value: { searchQuery: string; filteredOptions: CurrencyItem[] }) => void; customActionLabel?: React.ReactNode; onCustomAction?: () => void; classNames?: Record; selectProps?: Partial>; /** * Specify the number of decimal places to format the amount. When not specified, the number of * decimals is determined by the selected currency (e.g. 2 for EUR, 0 for JPY, 3 for BHD). * This override is ignored for zero-decimal currencies (e.g. JPY, KRW, HUF), which always use 0. */ decimals?: number; } export type MoneyInputPropsWithInputAttributes = MoneyInputProps & Partial; interface MoneyInputState { searchQuery: string; formattedAmount: string; locale: string; } class MoneyInput extends Component { declare props: MoneyInputPropsWithInputAttributes & Required>; static defaultProps = { size: Size.LARGE, classNames: {}, selectProps: {}, } satisfies Partial; amountFocused = false; constructor(props: MoneyInputProps) { super(props); this.state = { searchQuery: '', formattedAmount: formatAmountIfSet({ amount: props.amount, currency: props.selectedCurrency.currency, locale: props.intl.locale, decimals: props.decimals, }), locale: props.intl.locale, }; } UNSAFE_componentWillReceiveProps(nextProps: MoneyInputProps) { this.setState({ locale: nextProps.intl.locale }); if (!this.amountFocused) { this.setState({ formattedAmount: formatAmountIfSet({ amount: nextProps.amount, currency: nextProps.selectedCurrency.currency, locale: nextProps.intl.locale, decimals: nextProps.decimals, }), }); } } isInputAllowedForKeyEvent = (event: React.KeyboardEvent) => { const { metaKey, key, ctrlKey } = event; const isNumberKey = isNumber(Number.parseInt(key, 10)); return isNumberKey || metaKey || ctrlKey || allowedInputKeys.has(key); }; handleKeyDown: React.KeyboardEventHandler = (event) => { if (!this.isInputAllowedForKeyEvent(event)) { event.preventDefault(); } }; handlePaste: React.ClipboardEventHandler = (event) => { const paste = event.clipboardData.getData('text'); const { locale } = this.state; const parsed = isEmpty(paste) ? null : parseNumber({ amount: paste, currency: this.props.selectedCurrency.currency, locale, decimals: this.props.decimals, }); if (isNumberOrNull(parsed)) { this.setState({ formattedAmount: formatAmountIfSet({ amount: parsed, currency: this.props.selectedCurrency.currency, locale, decimals: this.props.decimals, }), }); this.props.onAmountChange?.(parsed); } event.preventDefault(); }; onAmountChange: React.ChangeEventHandler = (event) => { const { value } = event.target; this.setState({ formattedAmount: value, }); const parsed = isEmpty(value) ? null : parseNumber({ amount: value, currency: this.props.selectedCurrency.currency, locale: this.state.locale, decimals: this.props.decimals, }); if (isNumberOrNull(parsed)) { this.props.onAmountChange?.(parsed); } }; onAmountBlur = () => { this.amountFocused = false; this.setAmount(); }; onAmountFocus = () => { this.amountFocused = true; }; getSelectOptions() { const selectOptions = filterCurrenciesForQuery(this.props.currencies, this.state.searchQuery); const formattedOptions: SelectInputItem[] = []; let currentGroupOptions: SelectInputOptionItem[] | undefined; selectOptions.forEach((item) => { if (item.header != null) { currentGroupOptions = []; formattedOptions.push({ type: 'group', label: item.header, options: currentGroupOptions, }); } else { (currentGroupOptions ?? formattedOptions).push({ type: 'option', value: item, filterMatchers: [item.value, item.label, item.note ?? '', item.searchable ?? ''], }); } }); return formattedOptions; } setAmount() { this.setState((previousState) => { const parsed = parseNumber({ amount: previousState.formattedAmount, currency: this.props.selectedCurrency.currency, locale: previousState.locale, decimals: this.props.decimals, }); if (!isNumberOrNull(parsed)) { return { formattedAmount: previousState.formattedAmount, }; } return { formattedAmount: formatAmountIfSet({ amount: parsed, currency: this.props.selectedCurrency.currency, locale: previousState.locale, decimals: this.props.decimals, }), }; }); } handleSelectChange = (value: CurrencyOptionItem) => { this.handleSearchChange(''); this.props.onCurrencyChange?.(value); }; handleSearchChange = (searchQuery: string) => { this.setState({ searchQuery }); this.props.onSearchChange?.({ searchQuery, filteredOptions: filterCurrenciesForQuery(this.props.currencies, searchQuery), }); }; style = (className: string) => this.props.classNames[className] || className; render() { const { inputAttributes, id: amountInputId, 'aria-labelledby': ariaLabelledByProp, selectedCurrency, onCurrencyChange, size, addon, selectProps, } = this.props; const ariaLabelledBy = ariaLabelledByProp ?? inputAttributes?.['aria-labelledby']; const selectOptions = this.getSelectOptions(); const hasSingleCurrency = () => { if (selectOptions.length !== 0) { const firstItem = selectOptions[0]; if (selectOptions.length === 1) { if (firstItem.type === 'option') { return firstItem.value.currency === selectedCurrency.currency; } if (firstItem.type === 'group') { return ( firstItem.options.length === 1 && !(this.props.onCustomAction && this.props.customActionLabel) ); } } } else if (selectedCurrency?.currency) { return true; } return false; }; const isFixedCurrency = (!this.state.searchQuery && hasSingleCurrency()) || !onCurrencyChange; const disabled = !this.props.onAmountChange; const selectedCurrencyElementId = `${inputAttributes?.id ?? amountInputId}SelectedCurrency`; return (
{addon && ( {addon} )} {isFixedCurrency ? (
{(size === 'lg' || size === 'md') && ( )} {selectedCurrency.currency.toUpperCase()}
) : (
{ return ( {currency.currency.toUpperCase()} ) : ( currency.label ) } note={withinTrigger ? undefined : currency.note} icon={} /> ); }} renderFooter={ this.props.onCustomAction ? () => ( // eslint-disable-next-line jsx-a11y/click-events-have-key-events
{this.props.customActionLabel}
) : undefined } placeholder={this.props.intl.formatMessage(messages.selectPlaceholder)} filterable filterPlaceholder={ this.props.searchPlaceholder || this.props.intl.formatMessage(messages.searchPlaceholder) } disabled={disabled} size={size} onChange={this.handleSelectChange} onFilterChange={({ queryNormalized }) => { this.handleSearchChange(queryNormalized ?? ''); }} {...selectProps} />
)}
); } } function filterCurrenciesForQuery( currencies: readonly CurrencyItem[], query: string, ): CurrencyItem[] { if (!query) { return [...currencies]; } const options = currencies.filter( (option): option is CurrencyOptionItem => option.header == null, ); const filteredOptions = removeDuplicateValueOptions(options).filter((option) => currencyOptionFitsQuery(option, query), ); return sortOptionsLabelsToFirst(filteredOptions, query); } function removeDuplicateValueOptions(options: readonly CurrencyOptionItem[]) { const uniqueValues = new Set(); return options.filter((option) => { if (!uniqueValues.has(option.value)) { uniqueValues.add(option.value); return true; } return false; }); } function currencyOptionFitsQuery(option: CurrencyOptionItem, query: string) { if (!option.value) { return false; } return ( contains(option.label, query) || contains(option.searchable, query) || contains(option.note, query) ); } function contains(property: string | undefined, query: string) { return property?.toLowerCase().includes(query.toLowerCase()); } function sortOptionsLabelsToFirst(options: readonly CurrencyOptionItem[], query: string) { return [...options].sort((first, second) => { const firstContains = contains(first.label, query); const secondContains = contains(second.label, query); if (firstContains && secondContains) { return 0; } if (firstContains) { return -1; } if (secondContains) { return 1; } return 0; }); } export default injectIntl(withId(withInputAttributes(MoneyInput, { nonLabelable: true })));