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",
|
"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 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",
|
"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",
|
"Make child categories top level": "Convertir las categorías hijas en categorías principales",
|
||||||
"mail support": "escribir a soporte",
|
"mail support": "escribir a soporte",
|
||||||
"Making your money talk...": "Haciendo que tu dinero hable...",
|
"Making your money talk...": "Haciendo que tu dinero hable...",
|
||||||
|
|
|
||||||
|
|
@ -753,9 +753,10 @@ export function EditTransactionDialog({
|
||||||
selectedAccount?.currency_code ||
|
selectedAccount?.currency_code ||
|
||||||
'USD'
|
'USD'
|
||||||
}
|
}
|
||||||
placeholder="-25.00"
|
placeholder="25.00"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
required
|
required
|
||||||
|
allowNegative
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!selectedAccount?.banking_connection_id && (
|
{!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 { Input } from '@/components/ui/input';
|
||||||
import { useLocale } from '@/hooks/use-locale';
|
import { useLocale } from '@/hooks/use-locale';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { __ } from '@/utils/i18n';
|
||||||
|
|
||||||
interface AmountInputProps {
|
interface AmountInputProps {
|
||||||
value: number;
|
value: number;
|
||||||
|
|
@ -13,6 +14,7 @@ interface AmountInputProps {
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
id?: string;
|
id?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
allowNegative?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCurrencyInfo = (
|
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>(
|
export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
|
||||||
(
|
(
|
||||||
{
|
{
|
||||||
|
|
@ -164,6 +169,7 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
|
||||||
placeholder = '0.00',
|
placeholder = '0.00',
|
||||||
id,
|
id,
|
||||||
className = '',
|
className = '',
|
||||||
|
allowNegative = false,
|
||||||
},
|
},
|
||||||
ref,
|
ref,
|
||||||
) => {
|
) => {
|
||||||
|
|
@ -186,19 +192,15 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
|
||||||
if (value !== 0) {
|
if (value !== 0) {
|
||||||
const amount = (value / 100).toFixed(2);
|
const amount = (value / 100).toFixed(2);
|
||||||
setDisplayValue(amount);
|
setDisplayValue(amount);
|
||||||
} else {
|
} else if (!displayValue.startsWith('-')) {
|
||||||
|
// Keep a lone '-' the user set via the toggle before typing.
|
||||||
setDisplayValue('');
|
setDisplayValue('');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
|
onChange(resolveCents(displayValue));
|
||||||
// Try to evaluate as math expression first
|
|
||||||
const mathResult = evaluateMathExpression(displayValue);
|
|
||||||
const valueInCents = mathResult !== null ? mathResult : parseInputValue(displayValue);
|
|
||||||
|
|
||||||
onChange(valueInCents);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -207,20 +209,35 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
// Try to evaluate as math expression first
|
onChange(resolveCents(displayValue));
|
||||||
const mathResult = evaluateMathExpression(displayValue);
|
|
||||||
const valueInCents = mathResult !== null ? mathResult : parseInputValue(displayValue);
|
|
||||||
|
|
||||||
onChange(valueInCents);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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);
|
const { symbol: currencySymbol, position: symbolPosition } = getCurrencyInfo(currencyCode, locale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{symbolPosition === 'prefix' && (
|
{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}
|
{currencySymbol}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -239,7 +256,12 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
|
||||||
required={required}
|
required={required}
|
||||||
className={cn([
|
className={cn([
|
||||||
'bg-background',
|
'bg-background',
|
||||||
symbolPosition === 'prefix' ? 'pl-9' : 'pr-9',
|
symbolPosition === 'suffix' && 'pr-9',
|
||||||
|
allowNegative
|
||||||
|
? symbolPosition === 'prefix'
|
||||||
|
? 'pl-14'
|
||||||
|
: 'pl-11'
|
||||||
|
: symbolPosition === 'prefix' && 'pl-9',
|
||||||
className,
|
className,
|
||||||
])}
|
])}
|
||||||
/>
|
/>
|
||||||
|
|
@ -248,6 +270,24 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
|
||||||
{currencySymbol}
|
{currencySymbol}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue