fix(money-input): сохранять промежуточный ввод точки в draft
Реальная причина бага: classic problem controlled numeric input.
Когда юзер печатал «100.» (хотел «100.50»), цикл value→Number("100.")→100
→ display=String(100)=«100» съедал точку, и продолжать ввод дроби
становилось невозможно.
Фикс: MoneyInput хранит локальный draft string, который и показывается
в input. Снаружи value всё равно прокидывается числом, но draft не
синхронизируется с ним пока поле в фокусе. Промежуточные состояния
типа «100.» теперь живут в draft и не теряются.
- Добавлены useState<draft> и useState<focused>.
- onChange: пишем в draft as-is (только фильтр символов и одна точка),
наружу onChange(number) отдаём сразу когда draft парсится в число
(включая случай «100.» → отдаём 100, но draft оставляем «100.»).
- onBlur: commitDraft нормализует draft и в number, и обратно в draft.
- useEffect синхронизирует draft с value только когда !focused.
- Округление при !fractional не выполняется во время focus — иначе
перебивает ввод пользователя.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7d86b7ed73
commit
87271281b0
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from 'react'
|
||||
import { useEffect, useState, type InputHTMLAttributes, type SelectHTMLAttributes, type ReactNode, type TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||
|
||||
|
|
@ -48,51 +48,84 @@ interface MoneyInputProps {
|
|||
}
|
||||
|
||||
/** Денежное поле: только цифры (+ точка/запятая если allowFractional),
|
||||
* справа — символ валюты (₸/$/€). При allowFractional=false дробная часть
|
||||
* отбрасывается на лету и сразу синхронизируется в state наружу; иначе —
|
||||
* две цифры после запятой. Пустое поле → null. */
|
||||
* справа — символ валюты (₸/$/€). Хранит локальный draft string чтобы
|
||||
* пользователь мог посимвольно ввести "100.50" без того чтобы точка
|
||||
* пропадала на пути value→Number(...)→back. Снаружи прокидывается number
|
||||
* (или null если пусто); финальная нормализация — на onBlur. */
|
||||
export function MoneyInput({
|
||||
value, onChange, currencyCode, currencySymbol, allowFractional,
|
||||
disabled, placeholder = '0', className,
|
||||
}: MoneyInputProps) {
|
||||
const org = useOrgSettings()
|
||||
// Пока org.data не загружен и prop не задан — фактическое поведение
|
||||
// оставляем «как сейчас в state», чтобы не резать дробь до приезда настройки.
|
||||
const settingKnown = allowFractional !== undefined || org.data !== undefined
|
||||
const fractional = allowFractional ?? org.data?.allowFractionalPrices ?? true
|
||||
|
||||
// Когда настройка известна и она запрещает дробное, а в state лежит дробь —
|
||||
// синхронизируем округлённое значение наружу. Это нужно чтобы при сохранении
|
||||
// не уходило значение, которое юзер не видит на экране.
|
||||
const formatFromValue = (v: number | null | undefined, frac: boolean) =>
|
||||
v == null ? '' : (frac ? String(v) : String(Math.round(v)))
|
||||
|
||||
// Локальный draft — то что юзер реально печатает. Может быть промежуточным
|
||||
// ("100.", "100.5", "0."). Sync с внешним value только когда поле не в фокусе.
|
||||
const [draft, setDraft] = useState<string>(() => formatFromValue(value, fractional))
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!settingKnown || fractional) return
|
||||
if (focused) return
|
||||
setDraft(formatFromValue(value, fractional))
|
||||
}, [value, fractional, focused])
|
||||
|
||||
// Если настройка fractional=false, а в state лежит дробь — округляем наружу,
|
||||
// чтобы при сохранении ушло то, что пользователь видит. Не делаем пока юзер
|
||||
// активно печатает (focused) — иначе перебьём его ввод.
|
||||
useEffect(() => {
|
||||
if (!settingKnown || fractional || focused) return
|
||||
if (value == null) return
|
||||
const rounded = Math.round(value)
|
||||
if (rounded !== value) onChange(rounded)
|
||||
}, [settingKnown, fractional, value, onChange])
|
||||
}, [settingKnown, fractional, focused, value, onChange])
|
||||
|
||||
const suffix = currencySymbol || currencyCode || '₸'
|
||||
const display = value == null ? '' : (fractional ? String(value) : String(Math.round(value)))
|
||||
|
||||
const commitDraft = (raw: string) => {
|
||||
const cleaned = fractional ? raw.replace(/[^\d.]/g, '') : raw.replace(/[^\d]/g, '')
|
||||
if (cleaned === '' || cleaned === '.') { onChange(null); setDraft(''); return }
|
||||
const parts = cleaned.split('.')
|
||||
const normalized = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : cleaned
|
||||
const n = Number(normalized)
|
||||
if (!Number.isFinite(n)) { onChange(null); setDraft(''); return }
|
||||
const final = fractional ? n : Math.round(n)
|
||||
onChange(final)
|
||||
setDraft(formatFromValue(final, fractional))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<input
|
||||
type="text"
|
||||
inputMode={fractional ? 'decimal' : 'numeric'}
|
||||
disabled={disabled}
|
||||
value={display}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
onFocus={(e) => { setFocused(true); e.currentTarget.select() }}
|
||||
onBlur={() => { setFocused(false); commitDraft(draft) }}
|
||||
onChange={(e) => {
|
||||
let raw = e.target.value.replace(',', '.')
|
||||
raw = fractional ? raw.replace(/[^\d.]/g, '') : raw.replace(/[^\d]/g, '')
|
||||
if (raw === '') return onChange(null)
|
||||
if (fractional) {
|
||||
const parts = raw.split('.')
|
||||
raw = parts.length > 1 ? parts[0] + '.' + parts.slice(1).join('') : raw
|
||||
if (parts.length > 2) raw = parts[0] + '.' + parts.slice(1).join('')
|
||||
}
|
||||
// Draft сохраняем как есть — даже промежуточное "100." с точкой на конце,
|
||||
// чтобы пользователь мог продолжать печатать дробную часть.
|
||||
setDraft(raw)
|
||||
if (raw === '' || raw === '.') { onChange(null); return }
|
||||
// Промежуточное «100.» — снаружи отдаём 100 как число, draft не трогаем.
|
||||
if (fractional && raw.endsWith('.')) {
|
||||
const n = Number(raw.slice(0, -1))
|
||||
if (Number.isFinite(n)) onChange(n)
|
||||
return
|
||||
}
|
||||
const n = Number(raw)
|
||||
if (!Number.isFinite(n)) return
|
||||
onChange(fractional ? n : Math.round(n))
|
||||
if (Number.isFinite(n)) onChange(fractional ? n : Math.round(n))
|
||||
}}
|
||||
className={cn(inputClass, 'pr-10 text-right tabular-nums')}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue