Better input field UX

This commit is contained in:
Víctor Falcón 2025-11-26 11:24:37 +01:00
parent 9c22bc9e21
commit 5cfe3c81a1
9 changed files with 1707 additions and 62 deletions

View File

@ -62,6 +62,7 @@
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^7.0.0",
"playwright": "^1.57.0",
"prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.6.11",
@ -666,7 +667,7 @@
"frac": ["frac@1.1.2", "", {}, "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@ -908,6 +909,10 @@
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
"playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
@ -1158,6 +1163,10 @@
"rollup/@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.46.2", "", { "os": "win32", "cpu": "x64" }, "sha512-CvUo2ixeIQGtF6WvuB87XWqPQkoFAFqW+HUo/WzHwuHDvIwZCtjdWXoYCcr06iKGydiqTclC4jU/TNObC/xKZg=="],
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vite-plugin-full-reload/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],

View File

@ -25,6 +25,7 @@
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.1",
"pestphp/pest-plugin-browser": "^4.0",
"pestphp/pest-plugin-laravel": "^4.0"
},
"autoload": {

1563
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -21,6 +21,7 @@
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-react": "^7.37.3",
"eslint-plugin-react-hooks": "^7.0.0",
"playwright": "^1.57.0",
"prettier": "^3.4.2",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-tailwindcss": "^0.6.11",

View File

@ -1,11 +1,6 @@
import * as React from 'react';
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
InputGroupText,
} from '@/components/ui/input-group';
import { Input } from '@/components/ui/input';
interface AmountInputProps {
value: number;
@ -27,6 +22,26 @@ const getCurrencySymbol = (currencyCode: string): string => {
return symbols[currencyCode] || currencyCode;
};
const formatCurrency = (value: number): string => {
const amount = value / 100;
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
const parseInputValue = (input: string): number => {
const cleaned = input.replace(/[^\d.,]/g, '');
const normalized = cleaned.replace(',', '.');
const parsed = parseFloat(normalized);
if (isNaN(parsed)) {
return 0;
}
return Math.round(parsed * 100);
};
export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
(
{
@ -41,80 +56,60 @@ export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
ref,
) => {
const [displayValue, setDisplayValue] = React.useState<string>('');
const [isFocused, setIsFocused] = React.useState<boolean>(false);
React.useEffect(() => {
if (value === 0) {
setDisplayValue('');
if (!isFocused) {
if (value === 0) {
setDisplayValue('');
} else {
setDisplayValue(formatCurrency(value));
}
}
}, [value, isFocused]);
const handleFocus = () => {
setIsFocused(true);
if (value > 0) {
const amount = (value / 100).toFixed(2);
setDisplayValue(amount);
} else {
setDisplayValue((value / 100).toFixed(2));
setDisplayValue('');
}
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
if (inputValue === '' || inputValue === '-') {
setDisplayValue(inputValue);
onChange(0);
return;
}
const numericValue = inputValue.replace(/[^\d.-]/g, '');
if (numericValue === '' || numericValue === '-') {
setDisplayValue(numericValue);
onChange(0);
return;
}
const parsedValue = parseFloat(numericValue);
if (isNaN(parsedValue)) {
return;
}
setDisplayValue(numericValue);
const valueInCents = Math.round(parsedValue * 100);
onChange(valueInCents);
};
const handleBlur = () => {
if (displayValue === '' || displayValue === '-') {
setDisplayValue('');
onChange(0);
return;
}
const parsedValue = parseFloat(displayValue);
if (!isNaN(parsedValue)) {
setDisplayValue(parsedValue.toFixed(2));
}
setIsFocused(false);
const valueInCents = parseInputValue(displayValue);
onChange(valueInCents);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setDisplayValue(e.target.value);
};
const currencySymbol = getCurrencySymbol(currencyCode);
return (
<InputGroup>
<InputGroupAddon>
<InputGroupText>
{getCurrencySymbol(currencyCode)}
</InputGroupText>
</InputGroupAddon>
<InputGroupInput
<div className="relative">
<span className="-translate-y-1/2 absolute top-1/2 left-3 text-muted-foreground text-sm">
{currencySymbol}
</span>
<Input
ref={ref}
id={id}
type="text"
inputMode="decimal"
value={displayValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={placeholder}
disabled={disabled}
required={required}
className="bg-background pl-9"
/>
<InputGroupAddon align="inline-end">
<InputGroupText>{currencyCode}</InputGroupText>
</InputGroupAddon>
</InputGroup>
</div>
);
},
);

View File

@ -0,0 +1,74 @@
<?php
use App\Models\Account;
use App\Models\Category;
use App\Models\User;
use function Pest\Laravel\actingAs;
it('formats amount on blur', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$category = Category::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create(['user_id' => $user->id]);
actingAs($user);
$page = visit('/transactions');
$page->assertSee('Transactions')
->click('Add Transaction')
->wait(1)
->fill('#amount', '123.45')
->click('description')
->wait(0.5)
->assertNoJavascriptErrors();
});
it('accepts comma as decimal separator', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$category = Category::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create(['user_id' => $user->id]);
actingAs($user);
$page = visit('/transactions');
$page->assertSee('Transactions')
->click('Add Transaction')
->wait(1)
->fill('#amount', '10,50')
->click('description')
->wait(0.5)
->assertNoJavascriptErrors();
});
it('can create a transaction with amount input', function () {
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$category = Category::factory()->create(['user_id' => $user->id]);
$account = Account::factory()->create(['user_id' => $user->id]);
actingAs($user);
$page = visit('/transactions');
$page->assertSee('Transactions')
->click('Add Transaction')
->wait(1)
->fill('description', 'Test Transaction')
->click('Select Account')
->wait(0.5)
->click($account->name)
->click('Select Category')
->wait(0.5)
->click($category->name)
->fill('#amount', '123.45')
->click('Create')
->wait(2)
->assertNoJavascriptErrors();
$this->assertDatabaseHas('transactions', [
'user_id' => $user->id,
'description' => 'Test Transaction',
'amount' => 12345,
]);
});

View File

@ -11,7 +11,7 @@ beforeEach(function () {
});
it('can create an automation rule with visual builder', function () {
$user = User::factory()->create();
$user = User::factory()->create(['encryption_salt' => str_repeat('a', 24)]);
$category = Category::factory()->create(['user_id' => $user->id]);
actingAs($user);

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View File

@ -15,6 +15,10 @@ pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
pest()->extend(Tests\TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Browser');
/*
|--------------------------------------------------------------------------
| Expectations