fix(costs): three-decimal currencies stay typable and refunds work on iOS (#2175, #2176)

The split and receipt fields rejected the third decimal a KWD or BHD amount seeds, on desktop and in the mobile sheet, which share the currency-aware guard now. Signed amounts get a sign toggle on the numeric input because the iOS decimal pad has no minus key, and the under/over hint no longer swaps direction for a negative total.
This commit is contained in:
Maurice
2026-09-02 19:00:49 +02:00
committed by Maurice
parent c907e0d5e5
commit 7c857173cf
29 changed files with 199 additions and 34 deletions
@@ -1,4 +1,4 @@
// FE-COMP-NUMINPUT-001 to FE-COMP-NUMINPUT-008
// FE-COMP-NUMINPUT-001 to FE-COMP-NUMINPUT-014
//
// #1513: tapping a pre-populated numeric field put the caret at the end, so the first
// digit typed was appended rather than replacing the value — quantity 1 + "6" = "16".
@@ -16,9 +16,9 @@ beforeEach(() => {
})
afterEach(() => vi.unstubAllGlobals())
function Harness({ initial, mode }: { initial: string; mode?: NumericMode }) {
function Harness({ initial, mode, signToggleLabel }: { initial: string; mode?: NumericMode; signToggleLabel?: string }) {
const [v, setV] = useState(initial)
return <NumericInput value={v} onValueChange={setV} mode={mode} aria-label="field" />
return <NumericInput value={v} onValueChange={setV} mode={mode} signToggleLabel={signToggleLabel} aria-label="field" />
}
describe('NumericInput', () => {
@@ -115,6 +115,47 @@ describe('NumericInput', () => {
expect((screen.getByLabelText('field') as HTMLInputElement).inputMode).toBe('decimal')
})
it('FE-COMP-NUMINPUT-012: the sign toggle flips the sign of what is already there (#2176)', () => {
// iOS renders inputMode="decimal" as a pad of digits, separator and backspace, with
// no minus key, so a refund is untypeable on a phone without this button.
render(<Harness initial="" mode="signed-decimal" signToggleLabel="Toggle sign" />)
const input = screen.getByLabelText('field') as HTMLInputElement
const toggle = screen.getByRole('button', { name: 'Toggle sign' })
fireEvent.change(input, { target: { value: '12,50' } })
fireEvent.click(toggle)
expect(input.value).toBe('-12,50')
expect(toggle).toHaveAttribute('aria-pressed', 'true')
fireEvent.click(toggle)
expect(input.value).toBe('12,50')
expect(toggle).toHaveAttribute('aria-pressed', 'false')
// The button must not take the focus, or the phone keypad closes mid-entry.
expect(fireEvent.mouseDown(toggle)).toBe(false)
})
it('FE-COMP-NUMINPUT-013: an empty field toggles to a bare minus so the digits follow', () => {
render(<Harness initial="" mode="signed-decimal" signToggleLabel="Toggle sign" />)
const input = screen.getByLabelText('field') as HTMLInputElement
fireEvent.click(screen.getByRole('button', { name: 'Toggle sign' }))
expect(input.value).toBe('-')
// The caret stays in the field, so the next keystroke appends to the sign.
fireEvent.change(input, { target: { value: '-1' } })
expect(input.value).toBe('-1')
})
it('FE-COMP-NUMINPUT-014: there is no toggle without a label, and none on an unsigned field', () => {
const { unmount } = render(<Harness initial="" mode="signed-decimal" />)
expect(screen.queryByRole('button')).toBeNull()
unmount()
render(<Harness initial="" mode="decimal" signToggleLabel="Toggle sign" />)
expect(screen.queryByRole('button')).toBeNull()
})
it('FE-COMP-NUMINPUT-009: typing inside the one-frame window is not swallowed by the deferred select', async () => {
// The deferred select() exists for WebKit, but it must not fire *after* the user has
// started typing: it would select the half-typed value and the next character would
+53 -24
View File
@@ -24,6 +24,8 @@ const SANITIZERS: Record<NumericMode, (raw: string) => string> = {
},
}
const flipSign = (raw: string) => (raw.startsWith('-') ? raw.slice(1) : `-${raw}`)
type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | 'value'> & {
value: string | number | null | undefined
/** Receives the sanitized raw string. Callers keep their own state and commit logic. */
@@ -31,6 +33,13 @@ type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | '
mode?: NumericMode
/** Escape hatch for a field that must not steal the caret (none today). */
selectOnFocus?: boolean
/**
* Accessible name for a sign toggle rendered beside the field. Set it on a signed
* money field: iOS draws inputMode="decimal" as a pad with digits, separator and
* backspace only, so a refund (#2176) has no minus key to type. Ignored on the
* unsigned modes, where there is no sign to flip.
*/
signToggleLabel?: string
ref?: Ref<HTMLInputElement>
}
@@ -62,34 +71,54 @@ type Props = Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'onChange' | '
* others on blur. This owns only the part that was uniformly broken.
*/
export function NumericInput({
value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, ref, ...rest
value, onValueChange, mode = 'integer', selectOnFocus = true, onFocus, inputMode, signToggleLabel, ref, ...rest
}: Props) {
// Set while a deferred select() is queued; any input in that window cancels it.
const selectPending = useRef(false)
const raw = String(value ?? '')
const signable = mode === 'signed' || mode === 'signed-decimal'
return (
<input
{...rest}
ref={ref}
type="text"
inputMode={inputMode ?? (mode === 'integer' ? 'numeric' : 'decimal')}
value={value ?? ''}
onChange={e => {
selectPending.current = false
onValueChange(SANITIZERS[mode](e.target.value))
}}
onFocus={e => {
if (selectOnFocus) {
const el = e.currentTarget
el.select()
selectPending.current = true
requestAnimationFrame(() => {
if (selectPending.current && document.activeElement === el) el.select()
selectPending.current = false
})
}
onFocus?.(e)
}}
/>
<>
<input
{...rest}
ref={ref}
type="text"
inputMode={inputMode ?? (mode === 'integer' ? 'numeric' : 'decimal')}
value={value ?? ''}
onChange={e => {
selectPending.current = false
onValueChange(SANITIZERS[mode](e.target.value))
}}
onFocus={e => {
if (selectOnFocus) {
const el = e.currentTarget
el.select()
selectPending.current = true
requestAnimationFrame(() => {
if (selectPending.current && document.activeElement === el) el.select()
selectPending.current = false
})
}
onFocus?.(e)
}}
/>
{signToggleLabel && signable && (
<button
type="button"
aria-label={signToggleLabel}
aria-pressed={raw.startsWith('-')}
disabled={rest.disabled}
// Keeps the caret (and on a phone the keypad) in the field, so the sign can
// be flipped mid-entry and typing carries on where it left off.
onMouseDown={e => e.preventDefault()}
onClick={() => onValueChange(SANITIZERS[mode](flipSign(raw)))}
className="text-content-faint"
style={{ border: 0, background: 'none', padding: '0 2px', font: 'inherit', lineHeight: 1, cursor: 'pointer' }}
>
±
</button>
)}
</>
)
}