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:
parent
b9b507005f
commit
91ffeb917d
|
|
@ -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...",
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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('-');
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in New Issue