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

## Problem

On the create/edit transaction form, the amount field uses
`inputMode="decimal"`. On iOS the numeric keypad that this triggers has
**no minus key**, so users cannot type the leading `-` the input relies
on to record a negative amount (e.g. an outgoing transfer).

## Fix

Add an opt-in `allowNegative` prop to `AmountInput` that renders a small
`+`/`−` sign-toggle button at the **start** of the field. It works with
any keyboard, so it does not depend on the OS keypad. It is enabled only
on the create-transaction amount input; budgets, balances and loan
fields keep their current UI.

- The button turns red with a `−` when the amount is negative, and shows
`+` otherwise (`aria-pressed` reflects the state).
- Toggle fires on `click` (works for both pointer and keyboard);
`onPointerDown` `preventDefault` keeps the input focused to avoid a
blur/reformat race.
- A leading `-` set via the toggle **before** typing is preserved on
focus, so the sign is not silently dropped.
- The currency symbol shifts right when the toggle is present so the two
never overlap; layout is unchanged when `allowNegative` is off.

## Refactor

Extracted the repeated `evaluateMathExpression(x) ?? parseInputValue(x)`
string→cents resolution (previously duplicated across blur, Enter, and
the new toggle) into a single `resolveCents()` helper.

## Tests

`resources/js/components/ui/amount-input.test.tsx` — the toggle is
hidden unless `allowNegative`, flips positive↔negative, and preserves
the sign when focusing after toggling an empty field.

## QA

Manually verified in the running app (create-transaction dialog): typing
an amount and toggling the sign both ways updates the value and the
button state correctly, on both prefix (USD `$`) and suffix (EUR `€`)
currency layouts.
This commit is contained in:
Víctor Falcón 2026-07-14 18:13:39 +02:00 committed by GitHub
parent b9b507005f
commit 91ffeb917d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
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>
);
},