From 8eceff0bb5eab4c4712ed1cb8db0af2624855120 Mon Sep 17 00:00:00 2001 From: nurdotnet <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 3 May 2026 18:25:56 +0500 Subject: [PATCH] =?UTF-8?q?fix(phone):=20=D1=81=D0=BE=D1=85=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D1=8F=D1=82=D1=8C=20=D0=BF=D0=BE=D0=B7=D0=B8=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20=D0=BA=D1=83=D1=80=D1=81=D0=BE=D1=80=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Курсор не должен прыгать в конец после редактирования — он должен оставаться там, где юзер только что внёс изменение. Корень проблемы: после onChange я вызываю onChange с каноничным «+77…», parent перерендеривает с этим значением, React переписывает input.value на новый display — а нативное поведение браузера в этом случае: при programmatic value-set курсор сбрасывается в конец. Фикс: сохраняем «сколько цифр стояло до курсора» в момент редактирования, после ре-рендера в useLayoutEffect ставим курсор сразу после той же по счёту цифры в новом display. Учёт по цифрам (а не по абсолютной позиции) корректен даже когда format добавляет/убирает пробелы (например при переходе между «+7 700 1» и «+7 700 12»). useLayoutEffect (а не useEffect) — чтобы курсор восстанавливался синхронно до paint, без визуального флика. --- .../src/components/PhoneInput.tsx | 35 +++++++++++++- .../src/components/PhoneInput.tsx | 46 +++++++++++++++++-- 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/src/food-market.public/src/components/PhoneInput.tsx b/src/food-market.public/src/components/PhoneInput.tsx index 70642c4..f67a4b4 100644 --- a/src/food-market.public/src/components/PhoneInput.tsx +++ b/src/food-market.public/src/components/PhoneInput.tsx @@ -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, '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(null) const userDigits = useMemo(() => rawToUserDigits(value), [value]) const display = userDigitsToDisplay(userDigits) + const pendingDigitCountRef = useRef(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) => { const native = e.nativeEvent as InputEvent @@ -38,6 +69,8 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph } const handleChange = (e: React.ChangeEvent) => { + 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}` : '') } diff --git a/src/food-market.web/src/components/PhoneInput.tsx b/src/food-market.web/src/components/PhoneInput.tsx index 296395a..fe50631 100644 --- a/src/food-market.web/src/components/PhoneInput.tsx +++ b/src/food-market.web/src/components/PhoneInput.tsx @@ -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, 'value' | 'onChange' | 'type'> { /** Каноничное значение «+77XXXXXXXXX» либо пустая строка. */ value: string @@ -43,6 +66,21 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph const ref = useRef(null) const userDigits = useMemo(() => rawToUserDigits(value), [value]) const display = userDigitsToDisplay(userDigits) + // Запоминаем сколько цифр стояло до курсора в момент редактирования — + // после React-нормализации (которая может сдвинуть пробелы формата) + // ставим курсор обратно после той же по счёту цифры. Без этого React + // сбрасывал бы курсор в конец на каждом изменении value. + const pendingDigitCountRef = useRef(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) => { + 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}` : '') }