Better input field UX
This commit is contained in:
parent
9c22bc9e21
commit
5cfe3c81a1
11
bun.lock
11
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
});
|
||||
|
|
@ -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 |
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue