whisper-money/resources/js/components/ui/amount-input.tsx

298 lines
9.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as React from 'react';
import { Input } from '@/components/ui/input';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
interface AmountInputProps {
value: number;
onChange: (valueInCents: number) => void;
currencyCode: string;
disabled?: boolean;
required?: boolean;
placeholder?: string;
id?: string;
className?: string;
allowNegative?: boolean;
}
const getCurrencyInfo = (
currencyCode: string,
locale: string,
): { symbol: string; position: 'prefix' | 'suffix' } => {
const parts = new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).formatToParts(1);
const symbolPart = parts.find((p) => p.type === 'currency');
const symbol = symbolPart?.value ?? currencyCode;
const symbolIndex = parts.findIndex((p) => p.type === 'currency');
const literalIndex = parts.findIndex((p) => p.type === 'integer');
const position = symbolIndex < literalIndex ? 'prefix' : 'suffix';
return { symbol, position };
};
const formatCurrency = (value: number, locale: string): string => {
const amount = value / 100;
return new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
const parseInputValue = (input: string): number => {
const isNegative = input.trim().startsWith('-');
const cleaned = input.replace(/[^\d.,]/g, '');
if (!cleaned) {
return 0;
}
const lastComma = cleaned.lastIndexOf(',');
const lastDot = cleaned.lastIndexOf('.');
let normalized: string;
if (lastComma > lastDot) {
normalized = cleaned.replace(/\./g, '').replace(',', '.');
} else if (lastDot > lastComma) {
normalized = cleaned.replace(/,/g, '');
} else {
normalized = cleaned.replace(',', '.');
}
const parsed = parseFloat(normalized);
if (isNaN(parsed)) {
return 0;
}
const cents = Math.round(parsed * 100);
return isNegative ? -cents : cents;
};
const evaluateMathExpression = (input: string): number | null => {
// Check for leading minus (negative result)
const trimmed = input.trim();
const isNegativeResult = trimmed.startsWith('-');
const withoutLeadingMinus = isNegativeResult ? trimmed.substring(1) : trimmed;
// Check if input contains math operators (excluding leading minus)
if (!/[+\-*/]/.test(withoutLeadingMinus)) {
return null; // No math operation found
}
try {
// Remove spaces
const cleaned = withoutLeadingMinus.replace(/\s/g, '');
// Helper to convert parsed cents to dollars for calculation
const parseToDollars = (str: string): number => {
return parseInputValue(str) / 100;
};
// Split into tokens (numbers and operators)
const tokens: (number | string)[] = [];
let currentNumber = '';
for (let i = 0; i < cleaned.length; i++) {
const char = cleaned[i];
if (['+', '-', '*', '/'].includes(char) && currentNumber) {
tokens.push(parseToDollars(currentNumber));
tokens.push(char);
currentNumber = '';
} else {
currentNumber += char;
}
}
if (currentNumber) {
tokens.push(parseToDollars(currentNumber));
}
if (tokens.length < 3) {
return null; // Need at least: number operator number
}
// Handle multiplication and division first (operator precedence)
let i = 1;
while (i < tokens.length) {
if (tokens[i] === '*' || tokens[i] === '/') {
const left = tokens[i - 1] as number;
const op = tokens[i] as string;
const right = tokens[i + 1] as number;
const result = op === '*' ? left * right : left / right;
tokens.splice(i - 1, 3, result);
} else {
i += 2;
}
}
// Handle addition and subtraction
let result = tokens[0] as number;
for (let i = 1; i < tokens.length; i += 2) {
const op = tokens[i] as string;
const right = tokens[i + 1] as number;
if (op === '+') {
result += right;
} else if (op === '-') {
result -= right;
}
}
// Apply negative sign if the input started with minus
if (isNegativeResult) {
result = -result;
}
return Math.round(result * 100);
} catch {
return null;
}
};
const resolveCents = (input: string): number =>
evaluateMathExpression(input) ?? parseInputValue(input);
export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
(
{
value,
onChange,
currencyCode,
disabled = false,
required = false,
placeholder = '0.00',
id,
className = '',
allowNegative = false,
},
ref,
) => {
const locale = useLocale();
const [displayValue, setDisplayValue] = React.useState<string>('');
const [isFocused, setIsFocused] = React.useState<boolean>(false);
React.useEffect(() => {
if (!isFocused) {
if (value === 0) {
setDisplayValue('');
} else {
setDisplayValue(formatCurrency(value, locale));
}
}
}, [value, isFocused, locale]);
const handleFocus = () => {
setIsFocused(true);
if (value !== 0) {
const amount = (value / 100).toFixed(2);
setDisplayValue(amount);
} else if (!displayValue.startsWith('-')) {
// Keep a lone '-' the user set via the toggle before typing.
setDisplayValue('');
}
};
const handleBlur = () => {
setIsFocused(false);
onChange(resolveCents(displayValue));
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setDisplayValue(e.target.value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
onChange(resolveCents(displayValue));
}
};
// iOS numeric keypads (inputMode="decimal") have no minus key, so
// negative amounts need an explicit toggle. onClick handles both pointer
// and keyboard; onPointerDown preventDefault keeps the input focused,
// avoiding the blur/reformat race with the effect above.
const toggleSign = () => {
const next = displayValue.trim().startsWith('-')
? displayValue.replace('-', '')
: `-${displayValue}`;
setDisplayValue(next);
onChange(resolveCents(next));
};
const isNegative = displayValue.trim().startsWith('-');
const { symbol: currencySymbol, position: symbolPosition } = getCurrencyInfo(currencyCode, locale);
return (
<div className="relative">
{symbolPosition === 'prefix' && (
<span
className={cn([
'-translate-y-1/2 absolute top-1/2 text-muted-foreground text-sm',
allowNegative ? 'left-10' : 'left-3',
])}
>
{currencySymbol}
</span>
)}
<Input
ref={ref}
id={id}
type="text"
inputMode="decimal"
value={displayValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
required={required}
className={cn([
'bg-background',
symbolPosition === 'suffix' && 'pr-9',
allowNegative
? symbolPosition === 'prefix'
? 'pl-14'
: 'pl-11'
: symbolPosition === 'prefix' && 'pl-9',
className,
])}
/>
{symbolPosition === 'suffix' && (
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
{currencySymbol}
</span>
)}
{allowNegative && (
<button
type="button"
onPointerDown={(e) => e.preventDefault()}
onClick={toggleSign}
disabled={disabled}
aria-label={isNegative ? __('Make amount positive') : __('Make amount negative')}
aria-pressed={isNegative}
className={cn([
'-translate-y-1/2 absolute top-1/2 left-[0.35rem] flex h-7 w-7 items-center justify-center rounded-sm border font-semibold text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
isNegative
? 'border-destructive/40 bg-destructive/10 text-destructive'
: 'border-input bg-muted text-foreground hover:bg-accent',
])}
>
{isNegative ? '' : '+'}
</button>
)}
</div>
);
},
);
AmountInput.displayName = 'AmountInput';