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
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:
parent
1264a91e2c
commit
8eceff0bb5
|
|
@ -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_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)}`
|
||||
}
|
||||
|
||||
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'> {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
|
|
@ -29,6 +49,17 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
|
|||
const ref = useRef<HTMLInputElement>(null)
|
||||
const userDigits = useMemo(() => rawToUserDigits(value), [value])
|
||||
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 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 cursorPos = e.target.selectionStart ?? e.target.value.length
|
||||
pendingDigitCountRef.current = digitsBeforeCursor(e.target.value, cursorPos)
|
||||
const newDigits = rawToUserDigits(e.target.value)
|
||||
onChange(newDigits.length > 0 ? `+77${newDigits}` : '')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
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)}`
|
||||
}
|
||||
|
||||
/** Сколько цифровых символов стоит ДО позиции 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'> {
|
||||
/** Каноничное значение «+77XXXXXXXXX» либо пустая строка. */
|
||||
value: string
|
||||
|
|
@ -43,6 +66,21 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
|
|||
const ref = useRef<HTMLInputElement>(null)
|
||||
const userDigits = useMemo(() => rawToUserDigits(value), [value])
|
||||
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 для
|
||||
// deleteContentBackward и т.п. — там не вмешиваемся, пусть браузер удаляет.
|
||||
|
|
@ -54,9 +92,11 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
|
|||
}
|
||||
|
||||
// После любого нативного редактирования (печать/удаление/paste/drag-drop)
|
||||
// нормализуем значение. Если юзер случайно стер часть префикса — он
|
||||
// автоматически восстановится при ре-рендере (display всегда начинается с PREFIX).
|
||||
// нормализуем значение. Запоминаем позицию курсора в digit-индексах,
|
||||
// чтобы useLayoutEffect восстановил её после ре-рендера.
|
||||
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)
|
||||
onChange(newDigits.length > 0 ? `+77${newDigits}` : '')
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue