fix(phone): сохранять позицию курсора после нормализации
Some checks failed
CI / Backend (.NET 8) (push) Successful in 1m3s
CI / Web (React + Vite) (push) Successful in 41s
Docker Public / Build + push Public (push) Successful in 26s
Docker Web / Build + push Web (push) Successful in 32s
Docker Public / Deploy Public on stage (push) Successful in 10s
Docker Web / Deploy Web on stage (push) Successful in 11s
CI / POS (WPF, Windows) (push) Has been cancelled

Курсор не должен прыгать в конец после редактирования — он должен оставаться там, где юзер только что внёс изменение.

Корень проблемы: после onChange я вызываю onChange с каноничным «+77…», parent перерендеривает с этим значением, React переписывает input.value на новый display — а нативное поведение браузера в этом случае: при programmatic value-set курсор сбрасывается в конец.

Фикс: сохраняем «сколько цифр стояло до курсора» в момент редактирования, после ре-рендера в useLayoutEffect ставим курсор сразу после той же по счёту цифры в новом display. Учёт по цифрам (а не по абсолютной позиции) корректен даже когда format добавляет/убирает пробелы (например при переходе между «+7 700 1» и «+7 700 12»).

useLayoutEffect (а не useEffect) — чтобы курсор восстанавливался синхронно до paint, без визуального флика.
This commit is contained in:
nurdotnet 2026-05-03 18:25:56 +05:00
parent 1264a91e2c
commit 8eceff0bb5
2 changed files with 77 additions and 4 deletions

View file

@ -1,4 +1,4 @@
import { useMemo, useRef, type InputHTMLAttributes, type FormEvent } from 'react' import { useMemo, useRef, useLayoutEffect, type InputHTMLAttributes, type FormEvent } from 'react'
const PREFIX = '+7 7' const PREFIX = '+7 7'
const PREFIX_LEN = 4 const PREFIX_LEN = 4
@ -19,6 +19,26 @@ function userDigitsToDisplay(d: string): string {
return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}` return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`
} }
function digitsBeforeCursor(value: string, cursor: number): number {
let count = 0
for (let i = 0; i < cursor && i < value.length; i++) {
if (/\d/.test(value[i])) count++
}
return count
}
function cursorAfterNthDigit(value: string, n: number): number {
if (n <= 0) return 0
let count = 0
for (let i = 0; i < value.length; i++) {
if (/\d/.test(value[i])) {
count++
if (count === n) return i + 1
}
}
return value.length
}
interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> { interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
value: string value: string
onChange: (value: string) => void onChange: (value: string) => void
@ -29,6 +49,17 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
const ref = useRef<HTMLInputElement>(null) const ref = useRef<HTMLInputElement>(null)
const userDigits = useMemo(() => rawToUserDigits(value), [value]) const userDigits = useMemo(() => rawToUserDigits(value), [value])
const display = userDigitsToDisplay(userDigits) const display = userDigitsToDisplay(userDigits)
const pendingDigitCountRef = useRef<number | null>(null)
useLayoutEffect(() => {
const n = pendingDigitCountRef.current
pendingDigitCountRef.current = null
if (n === null) return
const el = ref.current
if (!el) return
const pos = Math.max(PREFIX_LEN, cursorAfterNthDigit(display, n))
el.setSelectionRange(pos, pos)
}, [display])
const handleBeforeInput = (e: FormEvent<HTMLInputElement>) => { const handleBeforeInput = (e: FormEvent<HTMLInputElement>) => {
const native = e.nativeEvent as InputEvent const native = e.nativeEvent as InputEvent
@ -38,6 +69,8 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
} }
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const cursorPos = e.target.selectionStart ?? e.target.value.length
pendingDigitCountRef.current = digitsBeforeCursor(e.target.value, cursorPos)
const newDigits = rawToUserDigits(e.target.value) const newDigits = rawToUserDigits(e.target.value)
onChange(newDigits.length > 0 ? `+77${newDigits}` : '') onChange(newDigits.length > 0 ? `+77${newDigits}` : '')
} }

View file

@ -1,4 +1,4 @@
import { useMemo, useRef, type InputHTMLAttributes, type FormEvent } from 'react' import { useMemo, useRef, useLayoutEffect, type InputHTMLAttributes, type FormEvent } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
const inputClass = 'w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50 dark:disabled:bg-slate-800/60 tabular-nums' const inputClass = 'w-full h-10 rounded-md border border-slate-300 dark:border-slate-600 bg-white dark:bg-slate-900 px-3 text-sm leading-none focus:outline-none focus:ring-2 focus:ring-[var(--color-brand)] disabled:opacity-60 disabled:bg-slate-50 dark:disabled:bg-slate-800/60 tabular-nums'
@ -28,6 +28,29 @@ function userDigitsToDisplay(d: string): string {
return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}` return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`
} }
/** Сколько цифровых символов стоит ДО позиции cursor в строке value. */
function digitsBeforeCursor(value: string, cursor: number): number {
let count = 0
for (let i = 0; i < cursor && i < value.length; i++) {
if (/\d/.test(value[i])) count++
}
return count
}
/** Позиция в value сразу ПОСЛЕ n-й цифры (1-indexed). Если n больше чем
* есть цифр возвращает конец строки. */
function cursorAfterNthDigit(value: string, n: number): number {
if (n <= 0) return 0
let count = 0
for (let i = 0; i < value.length; i++) {
if (/\d/.test(value[i])) {
count++
if (count === n) return i + 1
}
}
return value.length
}
interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> { interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
/** Каноничное значение «+77XXXXXXXXX» либо пустая строка. */ /** Каноничное значение «+77XXXXXXXXX» либо пустая строка. */
value: string value: string
@ -43,6 +66,21 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
const ref = useRef<HTMLInputElement>(null) const ref = useRef<HTMLInputElement>(null)
const userDigits = useMemo(() => rawToUserDigits(value), [value]) const userDigits = useMemo(() => rawToUserDigits(value), [value])
const display = userDigitsToDisplay(userDigits) const display = userDigitsToDisplay(userDigits)
// Запоминаем сколько цифр стояло до курсора в момент редактирования —
// после React-нормализации (которая может сдвинуть пробелы формата)
// ставим курсор обратно после той же по счёту цифры. Без этого React
// сбрасывал бы курсор в конец на каждом изменении value.
const pendingDigitCountRef = useRef<number | null>(null)
useLayoutEffect(() => {
const n = pendingDigitCountRef.current
pendingDigitCountRef.current = null
if (n === null) return
const el = ref.current
if (!el) return
const pos = Math.max(PREFIX_LEN, cursorAfterNthDigit(display, n))
el.setSelectionRange(pos, pos)
}, [display])
// Блокируем вставку не-цифр (типинг + paste + IME). data === null для // Блокируем вставку не-цифр (типинг + paste + IME). data === null для
// deleteContentBackward и т.п. — там не вмешиваемся, пусть браузер удаляет. // deleteContentBackward и т.п. — там не вмешиваемся, пусть браузер удаляет.
@ -54,9 +92,11 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
} }
// После любого нативного редактирования (печать/удаление/paste/drag-drop) // После любого нативного редактирования (печать/удаление/paste/drag-drop)
// нормализуем значение. Если юзер случайно стер часть префикса — он // нормализуем значение. Запоминаем позицию курсора в digit-индексах,
// автоматически восстановится при ре-рендере (display всегда начинается с PREFIX). // чтобы useLayoutEffect восстановил её после ре-рендера.
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const cursorPos = e.target.selectionStart ?? e.target.value.length
pendingDigitCountRef.current = digitsBeforeCursor(e.target.value, cursorPos)
const newDigits = rawToUserDigits(e.target.value) const newDigits = rawToUserDigits(e.target.value)
onChange(newDigits.length > 0 ? `+77${newDigits}` : '') onChange(newDigits.length > 0 ? `+77${newDigits}` : '')
} }