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 = '+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}` : '')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}` : '')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue