fix(amount-input): allow negative amounts via a sign toggle on iOS

iOS numeric keypads (inputMode="decimal") have no minus key, so users
could not enter negative amounts on the transaction form. Add an opt-in
`allowNegative` prop that renders a +/- sign-toggle button at the start of
the field, enabled on the create-transaction amount input.

- Toggle works via click (pointer + keyboard); onPointerDown preventDefault
  keeps the input focused to avoid the blur/reformat race.
- Preserve a leading '-' set before typing so the sign is not dropped.
- Extract shared string->cents resolution into resolveCents().
This commit is contained in:
Víctor Falcón 2026-07-14 18:07:32 +02:00
parent b9b507005f
commit 8dde51c1d9
4 changed files with 121 additions and 15 deletions

View File

@ -1038,6 +1038,8 @@
"Main Checking": "Cuenta Corriente Principal",
"Maintain and promptly update your account information": "Mantén y actualiza prontamente tu información de cuenta",
"Maintain and promptly update your account\\n information": "Mantén y actualiza puntualmente la información de tu cuenta",
"Make amount negative": "Hacer el importe negativo",
"Make amount positive": "Hacer el importe positivo",
"Make child categories top level": "Convertir las categorías hijas en categorías principales",
"mail support": "escribir a soporte",
"Making your money talk...": "Haciendo que tu dinero hable...",

View File

@ -753,9 +753,10 @@ export function EditTransactionDialog({
selectedAccount?.currency_code ||
'USD'
}
placeholder="-25.00"
placeholder="25.00"
disabled={isSubmitting}
required
allowNegative
/>
{!selectedAccount?.banking_connection_id && (

View File

@ -0,0 +1,63 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { AmountInput } from './amount-input';
vi.mock('@inertiajs/react', () => ({
usePage: () => ({ props: { locale: 'en-US' } }),
}));
describe('AmountInput sign toggle', () => {
it('is hidden unless allowNegative is set', () => {
const { rerender } = render(
<AmountInput value={0} onChange={vi.fn()} currencyCode="USD" />,
);
expect(screen.queryByRole('button')).toBeNull();
rerender(
<AmountInput value={0} onChange={vi.fn()} currencyCode="USD" allowNegative />,
);
expect(screen.getByRole('button')).toBeInTheDocument();
});
it('flips a typed positive amount to negative', () => {
const onChange = vi.fn();
render(
<AmountInput value={0} onChange={onChange} currencyCode="USD" allowNegative />,
);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '25' } });
fireEvent.click(screen.getByRole('button'));
expect(onChange).toHaveBeenCalledWith(-2500);
});
it('flips a negative amount back to positive', () => {
const onChange = vi.fn();
render(
<AmountInput value={0} onChange={onChange} currencyCode="USD" allowNegative />,
);
const input = screen.getByRole('textbox');
fireEvent.focus(input);
fireEvent.change(input, { target: { value: '-25' } });
fireEvent.click(screen.getByRole('button'));
expect(onChange).toHaveBeenLastCalledWith(2500);
});
it('keeps the negative sign when focusing after toggling an empty field', () => {
render(
<AmountInput value={0} onChange={vi.fn()} currencyCode="USD" allowNegative />,
);
const input = screen.getByRole('textbox');
fireEvent.click(screen.getByRole('button'));
fireEvent.focus(input);
expect(input).toHaveValue('-');
});
});

View File

@ -3,6 +3,7 @@ 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;
@ -13,6 +14,7 @@ interface AmountInputProps {
placeholder?: string;
id?: string;
className?: string;
allowNegative?: boolean;
}
const getCurrencyInfo = (
@ -153,6 +155,9 @@ const evaluateMathExpression = (input: string): number | null => {
}
};
const resolveCents = (input: string): number =>
evaluateMathExpression(input) ?? parseInputValue(input);
export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
(
{
@ -164,6 +169,7 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
placeholder = '0.00',
id,
className = '',
allowNegative = false,
},
ref,
) => {
@ -186,19 +192,15 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
if (value !== 0) {
const amount = (value / 100).toFixed(2);
setDisplayValue(amount);
} else {
} else if (!displayValue.startsWith('-')) {
// Keep a lone '-' the user set via the toggle before typing.
setDisplayValue('');
}
};
const handleBlur = () => {
setIsFocused(false);
// Try to evaluate as math expression first
const mathResult = evaluateMathExpression(displayValue);
const valueInCents = mathResult !== null ? mathResult : parseInputValue(displayValue);
onChange(valueInCents);
onChange(resolveCents(displayValue));
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -207,20 +209,35 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
// Try to evaluate as math expression first
const mathResult = evaluateMathExpression(displayValue);
const valueInCents = mathResult !== null ? mathResult : parseInputValue(displayValue);
onChange(valueInCents);
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="-translate-y-1/2 absolute top-1/2 left-3 text-muted-foreground text-sm">
<span
className={cn([
'-translate-y-1/2 absolute top-1/2 text-muted-foreground text-sm',
allowNegative ? 'left-10' : 'left-3',
])}
>
{currencySymbol}
</span>
)}
@ -239,7 +256,12 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
required={required}
className={cn([
'bg-background',
symbolPosition === 'prefix' ? 'pl-9' : 'pr-9',
symbolPosition === 'suffix' && 'pr-9',
allowNegative
? symbolPosition === 'prefix'
? 'pl-14'
: 'pl-11'
: symbolPosition === 'prefix' && 'pl-9',
className,
])}
/>
@ -248,6 +270,24 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
{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>
);
},