Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions client/src/components/budget-module/BudgetFormModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { Select } from '../common/select'
import { Button } from '../common/button'
import { useAlert } from '../../context/AlertContext'
import type { Budget, BudgetFormData, BudgetAccountScope, BudgetCategoryScope, BudgetPeriod } from './types'
import { AmountInput } from '../common/amount-input'
import { formatAmount, parseAmount } from '../../lib/amount'

type Account = {
id: string
Expand Down Expand Up @@ -92,7 +94,7 @@ export function BudgetFormModal({

setForm({
name: initialData.name ?? '',
amount: String(initialData.amount),
amount: formatAmount(initialData.amount),
period: initialData.period,
year: year || currentYear,
month: month || 1,
Expand All @@ -111,7 +113,7 @@ export function BudgetFormModal({

const handleSave = async () => {
setError(null)
const amountValue = Number(form.amount)
const amountValue = parseAmount(form.amount)
if (!amountValue || amountValue <= 0) {
setError('Enter a valid amount greater than 0.')
return
Expand Down Expand Up @@ -185,12 +187,11 @@ export function BudgetFormModal({
</div>
<div>
<label className="text-xs font-semibold text-muted-foreground">Amount ({masterCurrency})</label>
<Input
type="number"
<AmountInput
min="0"
placeholder="100000"
value={form.amount}
onChange={(event) => setForm(prev => ({ ...prev, amount: event.target.value }))}
onValueChange={amount => setForm(prev => ({ ...prev, amount }))}
/>
</div>
</div>
Expand Down
144 changes: 144 additions & 0 deletions client/src/components/common/amount-input.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useState } from 'react'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it } from 'vitest'
import { AmountInput } from './amount-input'

function Harness({
allowNegative = false,
initialValue = '',
}: {
allowNegative?: boolean
initialValue?: string
}) {
const [value, setValue] = useState(initialValue)
return (
<AmountInput
aria-label="Amount"
allowNegative={allowNegative}
value={value}
onValueChange={setValue}
/>
)
}

describe('AmountInput', () => {
it('preserves grouping while deleting and refilling an existing suffix', async () => {
const user = userEvent.setup()
render(<Harness initialValue="120 000" />)
const input = screen.getByRole('textbox', { name: 'Amount' }) as HTMLInputElement

await user.click(input)

await user.keyboard('{Backspace}')
expect(input).toHaveValue('120 00')
expect(input.selectionStart).toBe(6)

await user.keyboard('{Backspace}')
expect(input).toHaveValue('120 0')
expect(input.selectionStart).toBe(5)

await user.keyboard('{Backspace}')
expect(input).toHaveValue('120 ')
expect(input.selectionStart).toBe(4)

await user.keyboard('3')
expect(input).toHaveValue('120 3')

await user.keyboard('0')
expect(input).toHaveValue('120 30')

await user.keyboard('0')
expect(input).toHaveValue('120 300')
expect(input.selectionStart).toBe(7)
})

it('resumes grouping when the integer grows beyond the session width', async () => {
const user = userEvent.setup()
render(<Harness initialValue="120 000" />)
const input = screen.getByRole('textbox', { name: 'Amount' }) as HTMLInputElement

await user.click(input)
await user.keyboard('{Backspace}{Backspace}{Backspace}3000')

expect(input).toHaveValue('1 203 000')
expect(input.selectionStart).toBe(9)
})

it('restores canonical grouping after growing and returning to the original width', async () => {
const user = userEvent.setup()
render(<Harness initialValue="120 000" />)
const input = screen.getByRole('textbox', { name: 'Amount' }) as HTMLInputElement

await user.click(input)
await user.keyboard('0')
expect(input).toHaveValue('1 200 000')

await user.keyboard('{Backspace}')
expect(input).toHaveValue('120 000')

await user.keyboard('{Backspace}')
expect(input).toHaveValue('120 00')
})

it('groups a fresh value as it grows and resets after a full clear', async () => {
const user = userEvent.setup()
render(<Harness initialValue="999 999" />)
const input = screen.getByRole('textbox', { name: 'Amount' }) as HTMLInputElement

await user.clear(input)
await user.type(input, '1200')

expect(input).toHaveValue('1 200')
expect(input.selectionStart).toBe(5)
})

it('keeps the caret beside the edited digit when grouping changes in the middle', async () => {
const user = userEvent.setup()
render(<Harness initialValue="123" />)
const input = screen.getByRole('textbox', { name: 'Amount' }) as HTMLInputElement

await user.click(input)
input.setSelectionRange(1, 1)
await user.keyboard('0')

expect(input).toHaveValue('1 023')
expect(input.selectionStart).toBe(3)
expect(input.selectionEnd).toBe(3)
})

it('canonicalizes a genuinely smaller value on blur', async () => {
const user = userEvent.setup()
render(<Harness initialValue="120 000" />)
const input = screen.getByRole('textbox', { name: 'Amount' })

await user.click(input)
await user.keyboard('{Backspace}{Backspace}{Backspace}')
expect(input).toHaveValue('120 ')

await user.tab()
expect(input).toHaveValue('120')
})

it('supports negative account-style values when enabled', async () => {
const user = userEvent.setup()
render(<Harness allowNegative initialValue="-120 000" />)
const input = screen.getByRole('textbox', { name: 'Amount' })

await user.click(input)
await user.keyboard('{Backspace}{Backspace}{Backspace}300')

expect(input).toHaveValue('-120 300')
})

it('normalizes pasted NBSP grouping and a decimal comma', async () => {
const user = userEvent.setup()
render(<Harness />)
const input = screen.getByRole('textbox', { name: 'Amount' })

await user.click(input)
await user.paste('12\u00a0345,67')

expect(input).toHaveValue('12 345.67')
})
})
200 changes: 200 additions & 0 deletions client/src/components/common/amount-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import * as React from 'react'
import {
formatAmount,
getAmountIntegerDigitCount,
normalizeAmountDraft,
} from '../../lib/amount'
import { Input, type InputProps } from './input'

export interface AmountInputProps extends Omit<
InputProps,
'defaultValue' | 'inputMode' | 'onChange' | 'type' | 'value'
> {
value: string
onValueChange?: (value: string) => void
onChange?: React.ChangeEventHandler<HTMLInputElement>
allowNegative?: boolean
}

type PendingSelection = {
start: number
end: number
value: string
}

const isGroupSeparator = (character: string) => /[\s\u00a0\u202f]/.test(character)

function logicalCharacterCount(value: string, end: number): number {
let count = 0
for (const character of value.slice(0, end)) {
if (!isGroupSeparator(character)) count += 1
}
return count
}

function positionAfterLogicalCharacters(value: string, logicalCount: number): number {
if (logicalCount === 0) return 0

let seen = 0
for (let index = 0; index < value.length; index += 1) {
if (!isGroupSeparator(value[index])) seen += 1
if (seen === logicalCount) return index + 1
}

return value.length
}

function mapSelection(
candidate: string,
formatted: string,
selectionStart: number,
selectionEnd: number,
): Pick<PendingSelection, 'start' | 'end'> {
if (candidate === formatted) {
return { start: selectionStart, end: selectionEnd }
}

return {
start: positionAfterLogicalCharacters(
formatted,
logicalCharacterCount(candidate, selectionStart),
),
end: positionAfterLogicalCharacters(
formatted,
logicalCharacterCount(candidate, selectionEnd),
),
}
}

function assignRef<T>(ref: React.ForwardedRef<T>, value: T | null) {
if (typeof ref === 'function') {
ref(value)
} else if (ref) {
ref.current = value
}
}

export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
(
{
allowNegative = false,
onBlur,
onChange,
onFocus,
onValueChange,
value,
...props
},
forwardedRef,
) => {
const inputRef = React.useRef<HTMLInputElement | null>(null)
const focusedRef = React.useRef(false)
const originalIntegerDigitsRef = React.useRef(getAmountIntegerDigitCount(value))
const pendingSelectionRef = React.useRef<PendingSelection | null>(null)
const lastRenderedValueRef = React.useRef(value)
const lastEmittedValueRef = React.useRef<string | null>(null)

const setInputRef = React.useCallback((node: HTMLInputElement | null) => {
inputRef.current = node
assignRef(forwardedRef, node)
}, [forwardedRef])

React.useLayoutEffect(() => {
const pending = pendingSelectionRef.current
const input = inputRef.current
if (!pending || !input || input.value !== pending.value) return

input.setSelectionRange(pending.start, pending.end)
pendingSelectionRef.current = null
})

React.useEffect(() => {
if (value === lastEmittedValueRef.current) {
lastEmittedValueRef.current = null
} else if (focusedRef.current && value !== lastRenderedValueRef.current) {
originalIntegerDigitsRef.current = getAmountIntegerDigitCount(value)
}

lastRenderedValueRef.current = value
}, [value])

const emitValue = React.useCallback((nextValue: string) => {
lastEmittedValueRef.current = nextValue
lastRenderedValueRef.current = nextValue
onValueChange?.(nextValue)
}, [onValueChange])

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const candidate = normalizeAmountDraft(event.currentTarget.value, { allowNegative })
if (candidate === null) return

const integerDigits = getAmountIntegerDigitCount(candidate)
const selectionStart = event.currentTarget.selectionStart ?? candidate.length
const selectionEnd = event.currentTarget.selectionEnd ?? selectionStart

let nextValue: string

if (candidate === '') {
originalIntegerDigitsRef.current = 0
nextValue = ''
} else {
// Keep the user's existing group scaffold only while the value is below
// the precision it had when this edit started. If it grew first and is
// then reduced back to that original precision, canonicalize it again.
if (
candidate.includes(' ') &&
integerDigits < originalIntegerDigitsRef.current
) {
nextValue = candidate
} else {
nextValue = formatAmount(candidate, { allowNegative })
}
}

const mapped = mapSelection(candidate, nextValue, selectionStart, selectionEnd)
pendingSelectionRef.current = { ...mapped, value: nextValue }

event.currentTarget.value = nextValue
emitValue(nextValue)
onChange?.(event)
event.currentTarget.setSelectionRange(mapped.start, mapped.end)
}

const handleFocus = (event: React.FocusEvent<HTMLInputElement>) => {
focusedRef.current = true
originalIntegerDigitsRef.current = getAmountIntegerDigitCount(event.currentTarget.value)
lastRenderedValueRef.current = event.currentTarget.value
onFocus?.(event)
}

const handleBlur = (event: React.FocusEvent<HTMLInputElement>) => {
const canonical = formatAmount(event.currentTarget.value, { allowNegative })

focusedRef.current = false
originalIntegerDigitsRef.current = getAmountIntegerDigitCount(canonical)

if (canonical !== value) {
event.currentTarget.value = canonical
emitValue(canonical)
onChange?.(event as unknown as React.ChangeEvent<HTMLInputElement>)
}

onBlur?.(event)
}

return (
<Input
{...props}
ref={setInputRef}
type="text"
inputMode="decimal"
value={value}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
/>
)
},
)

AmountInput.displayName = 'AmountInput'
Loading
Loading