Group the digits as you type in the ledger

-2000000 and -20000000 are the same shape at a glance, and the ledger deals
in both.

Display only: what leaves the input is always the raw string, so nothing
upstream ever sees a comma. Partly-typed numbers survive intact -- "1." and
"-" and "1.50" are all states on the way to a value, and reformatting them
into something else mid-keystroke makes the field unusable.

The caret is restored by counting digits rather than remembering an offset,
since inserting a comma shifts every character after it and a remembered
position lands one place off for the rest of the number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-18 12:28:31 -04:00
parent b4f579c4b1
commit 50f4c50b3a

View File

@ -11,7 +11,7 @@
// flex-1 buttons grew to absurd sizes; everything here is fixed-width and
// left-aligned instead.
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef, useLayoutEffect } from 'react'
const INPUT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-28 text-right font-mono tabular-nums'
const TEXT = 'border border-gray-200 rounded px-2 py-1 text-xs bg-white w-40 font-mono'
@ -259,11 +259,59 @@ function derive(current, edit, dp = 2) {
return out
}
// Grouped while you type, because -2000000 and -20000000 are the same shape at
// a glance and the ledger deals in both.
//
// Formats for display only: what leaves here is always the raw string, so the
// arithmetic upstream never sees a comma. A partly-typed number has to survive
// intact -- "1." and "-" and "1.50" are all states on the way to a value, and
// reformatting them into something else as you type makes the field unusable.
function groupDigits(raw) {
const str = String(raw ?? '')
if (str === '' || str === '-') return str
const neg = str.startsWith('-')
const body = neg ? str.slice(1) : str
const dot = body.indexOf('.')
const whole = (dot === -1 ? body : body.slice(0, dot)).replace(/\D/g, '')
const frac = dot === -1 ? null : body.slice(dot + 1).replace(/\D/g, '')
if (whole === '' && frac === null) return neg ? '-' : ''
const grouped = whole.replace(/\B(?=(\d{3})+(?!\d))/g, ',')
return `${neg ? '-' : ''}${grouped}${dot === -1 ? '' : `.${frac}`}`
}
function LedgerInput({ value, active, onChange, onFocus, suffix }) {
const ref = useRef(null)
const caret = useRef(null)
const display = groupDigits(value)
// The commas shift every character after them, so a remembered offset lands
// in the wrong place. Count digits instead -- those are what the caret is
// actually sitting between -- and find that many digits into the new text.
useLayoutEffect(() => {
const el = ref.current
if (!el || caret.current == null) return
const wanted = caret.current
caret.current = null
let seen = 0, pos = display.length
for (let i = 0; i < display.length; i++) {
if (/[\d.-]/.test(display[i])) seen++
if (seen === wanted) { pos = i + 1; break }
}
if (wanted === 0) pos = 0
try { el.setSelectionRange(pos, pos) } catch {}
}, [display])
return (
<span className="inline-flex items-center gap-1">
<input
type="text" inputMode="decimal" value={value} onChange={e => onChange(e.target.value)}
ref={ref}
type="text" inputMode="decimal" value={display}
onChange={e => {
const el = e.target
const upto = el.value.slice(0, el.selectionStart ?? el.value.length)
caret.current = (upto.match(/[\d.-]/g) || []).length
onChange(el.value.replace(/,/g, ''))
}}
onFocus={onFocus} placeholder="—"
className={`border rounded px-2 py-0.5 text-xs w-24 text-right font-mono tabular-nums
${active ? 'border-blue-400 bg-blue-50/40 text-gray-800' : 'border-gray-200 bg-white text-gray-700'}`} />