From 91ffeb917d1f35be7d37d5f01434233b14aa9b45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 14 Jul 2026 18:13:39 +0200 Subject: [PATCH] fix(amount-input): allow negative amounts via a sign toggle on iOS (#674) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- lang/es.json | 2 + .../transactions/edit-transaction-dialog.tsx | 3 +- .../js/components/ui/amount-input.test.tsx | 63 +++++++++++++++++ resources/js/components/ui/amount-input.tsx | 68 +++++++++++++++---- 4 files changed, 121 insertions(+), 15 deletions(-) create mode 100644 resources/js/components/ui/amount-input.test.tsx diff --git a/lang/es.json b/lang/es.json index 9845cae4..66138f8e 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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...", diff --git a/resources/js/components/transactions/edit-transaction-dialog.tsx b/resources/js/components/transactions/edit-transaction-dialog.tsx index 159934a2..20194352 100644 --- a/resources/js/components/transactions/edit-transaction-dialog.tsx +++ b/resources/js/components/transactions/edit-transaction-dialog.tsx @@ -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 && ( diff --git a/resources/js/components/ui/amount-input.test.tsx b/resources/js/components/ui/amount-input.test.tsx new file mode 100644 index 00000000..9cf8e528 --- /dev/null +++ b/resources/js/components/ui/amount-input.test.tsx @@ -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( + , + ); + expect(screen.queryByRole('button')).toBeNull(); + + rerender( + , + ); + expect(screen.getByRole('button')).toBeInTheDocument(); + }); + + it('flips a typed positive amount to negative', () => { + const onChange = vi.fn(); + render( + , + ); + + 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( + , + ); + + 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( + , + ); + + const input = screen.getByRole('textbox'); + fireEvent.click(screen.getByRole('button')); + fireEvent.focus(input); + + expect(input).toHaveValue('-'); + }); +}); diff --git a/resources/js/components/ui/amount-input.tsx b/resources/js/components/ui/amount-input.tsx index bde95c63..44572481 100644 --- a/resources/js/components/ui/amount-input.tsx +++ b/resources/js/components/ui/amount-input.tsx @@ -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( ( { @@ -164,6 +169,7 @@ export const AmountInput = React.forwardRef( placeholder = '0.00', id, className = '', + allowNegative = false, }, ref, ) => { @@ -186,19 +192,15 @@ export const AmountInput = React.forwardRef( 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) => { @@ -207,20 +209,35 @@ export const AmountInput = React.forwardRef( const handleKeyDown = (e: React.KeyboardEvent) => { 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 (
{symbolPosition === 'prefix' && ( - + {currencySymbol} )} @@ -239,7 +256,12 @@ export const AmountInput = React.forwardRef( 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( {currencySymbol} )} + {allowNegative && ( + + )}
); },