-
Joel Jeremy Marquez authored
* Add colors to mobile transaction amount input * VRT updates * Release notes
Joel Jeremy Marquez authored* Add colors to mobile transaction amount input * VRT updates * Release notes
FocusableAmountInput.tsx 7.21 KiB
import React, {
type Ref,
type ComponentPropsWithRef,
type HTMLProps,
memo,
useEffect,
useRef,
useState,
} from 'react';
import {
toRelaxedNumber,
amountToCurrency,
getNumberFormat,
} from 'loot-core/src/shared/util';
import { useLocalPref } from '../../../hooks/useLocalPref';
import { useMergedRefs } from '../../../hooks/useMergedRefs';
import { type CSSProperties, theme } from '../../../style';
import { makeAmountFullStyle } from '../../budget/util';
import { Button } from '../../common/Button';
import { Text } from '../../common/Text';
import { View } from '../../common/View';
type AmountInputProps = {
value: number;
focused?: boolean;
style?: CSSProperties;
textStyle?: CSSProperties;
inputRef?: Ref<HTMLInputElement>;
onFocus?: HTMLProps<HTMLInputElement>['onFocus'];
onBlur?: HTMLProps<HTMLInputElement>['onBlur'];
onEnter?: HTMLProps<HTMLInputElement>['onKeyUp'];
onChangeValue?: (value: string) => void;
onUpdate?: (value: string) => void;
onUpdateAmount?: (value: number) => void;
};
const AmountInput = memo(function AmountInput({
focused,
style,
textStyle,
...props
}: AmountInputProps) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState('');
const [value, setValue] = useState(0);
const inputRef = useRef<HTMLInputElement>();
const [hideFraction = false] = useLocalPref('hideFraction');
const mergedInputRef = useMergedRefs<HTMLInputElement>(
props.inputRef,
inputRef,
);
const initialValue = Math.abs(props.value);
useEffect(() => {
if (focused) {
inputRef.current?.focus();
}
}, [focused]);
useEffect(() => {
setEditing(false);
setText('');
setValue(initialValue);
}, [initialValue]);
const parseText = () => {
return toRelaxedNumber(text.replace(/[,.]/, getNumberFormat().separator));
};
const onKeyUp: HTMLProps<HTMLInputElement>['onKeyUp'] = e => {
if (e.key === 'Backspace' && text === '') {
setEditing(true);
} else if (e.key === 'Enter') {
props.onEnter?.(e);
if (!e.defaultPrevented) {
onUpdate(e.currentTarget.value);
}
}
};
const applyText = () => {
const parsed = parseText();
const newValue = editing ? parsed : value;
setValue(Math.abs(newValue));
setEditing(false);
setText('');
return newValue;
};
const onFocus: HTMLProps<HTMLInputElement>['onFocus'] = e => {
props.onFocus?.(e);
};
const onUpdate = (value: string) => {
props.onUpdate?.(value);
const amount = applyText();
props.onUpdateAmount?.(amount);
};
const onBlur: HTMLProps<HTMLInputElement>['onBlur'] = e => {
props.onBlur?.(e);
if (!e.defaultPrevented) {
onUpdate(e.target.value);
}
};
const onChangeText = (text: string) => {
if (text.slice(-1) === '.') {
text = text.slice(0, -1);
}
if (!hideFraction) {
text = text.replaceAll(/[,.]/g, '');
text = text.replace(/^0+(?!$)/, '');
text = text.padStart(3, '0');
text = text.slice(0, -2) + '.' + text.slice(-2);
}
setEditing(true);
setText(text);
props.onChangeValue?.(text);
};
const input = (
<input
type="text"
ref={mergedInputRef}
value={text}
inputMode="decimal"
autoCapitalize="none"
onChange={e => onChangeText(e.target.value)}
onFocus={onFocus}
onBlur={onBlur}
onKeyUp={onKeyUp}
data-testid="amount-input"
style={{ flex: 1, textAlign: 'center', position: 'absolute' }}
/>
);
return (
<View
style={{
justifyContent: 'center',
borderWidth: 1,
borderColor: theme.pillBorderSelected,
borderRadius: 4,
padding: 5,
backgroundColor: theme.tableBackground,
maxWidth: 'calc(100% - 40px)',
...style,
}}
>
<View style={{ overflowY: 'auto', overflowX: 'hidden' }}>{input}</View>
<Text
style={{
pointerEvents: 'none',
...textStyle,
}}
data-testid="amount-fake-input"
>
{editing ? amountToCurrency(text) : amountToCurrency(value)}
</Text>
</View>
);
});
type FocusableAmountInputProps = Omit<AmountInputProps, 'onFocus'> & {
sign?: '+' | '-';
zeroSign?: '+' | '-';
focused?: boolean;
disabled?: boolean;
focusedStyle?: CSSProperties;
buttonProps?: ComponentPropsWithRef<typeof Button>;
onFocus?: () => void;
};
export const FocusableAmountInput = memo(function FocusableAmountInput({
value,
sign,
zeroSign,
focused,
disabled,
textStyle,
style,
focusedStyle,
buttonProps,
onFocus,
onBlur,
...props
}: FocusableAmountInputProps) {
const [isNegative, setIsNegative] = useState(true);
const maybeApplyNegative = (amount: number, negative: boolean) => {
const absValue = Math.abs(amount);
return negative ? -absValue : absValue;
};
const onUpdateAmount = (amount: number, negative: boolean) => {
props.onUpdateAmount?.(maybeApplyNegative(amount, negative));
};
useEffect(() => {
if (sign) {
setIsNegative(sign === '-');
} else if (value > 0 || (zeroSign !== '-' && value === 0)) {
setIsNegative(false);
}
}, [sign, value, zeroSign]);
const toggleIsNegative = () => {
if (disabled) {
return;
}
onUpdateAmount(value, !isNegative);
setIsNegative(!isNegative);
};
return (
<View>
<AmountInput
{...props}
value={value}
onFocus={onFocus}
onBlur={onBlur}
onUpdateAmount={amount => onUpdateAmount(amount, isNegative)}
focused={focused && !disabled}
style={{
...makeAmountFullStyle(value, {
zeroColor: isNegative ? theme.errorText : theme.noticeText,
}),
width: 80,
justifyContent: 'center',
...style,
...focusedStyle,
...(!focused && {
display: 'none',
}),
}}
textStyle={{ fontSize: 15, textAlign: 'right', ...textStyle }}
/>
<View>
{!focused && (
<Button
style={{
position: 'absolute',
right: 'calc(100% + 5px)',
top: '8px',
}}
onClick={toggleIsNegative}
>
{isNegative ? '-' : '+'}
</Button>
)}
<Button
onClick={onFocus}
// Defines how far touch can start away from the button
// hitSlop={{ top: 5, bottom: 5, left: 5, right: 5 }}
{...buttonProps}
style={{
...(buttonProps && buttonProps.style),
...(focused && { display: 'none' }),
':hover': {
backgroundColor: 'transparent',
},
}}
type="bare"
>
<View
style={{
borderBottomWidth: 1,
borderColor: '#e0e0e0',
justifyContent: 'center',
...style,
}}
>
<Text
style={{
...makeAmountFullStyle(value),
fontSize: 15,
userSelect: 'none',
...textStyle,
}}
>
{amountToCurrency(Math.abs(value))}
</Text>
</View>
</Button>
</View>
</View>
);
});