fix(phone): редактирование на месте курсора, как в обычном поле
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m3s
CI / Web (React + Vite) (push) Successful in 39s
Docker Public / Build + push Public (push) Successful in 25s
Docker Web / Build + push Web (push) Successful in 31s
Docker Public / Deploy Public on stage (push) Successful in 10s
Docker Web / Deploy Web on stage (push) Successful in 11s

Курсор больше не «прыгает в конец» — его можно ставить куда угодно в digit-зоне (после префикса «+7 »), а Backspace/Delete/ввод цифры работают относительно позиции курсора, как в любом нормальном текстовом поле.

Реализация:
- cursorToDigitIdx — мапит позицию курсора в display к индексу в массиве цифр (пробелы форматирования пропускаются).
- digitIdxToCursor — обратное: после операции ставит курсор сразу после последней изменённой цифры (через пробел если он там есть, чтобы не было визуального скачка).
- Backspace на позиции idx удаляет цифру (idx-1); Delete удаляет цифру idx; ввод цифры вставляет в позицию idx.
- Префикс «+7 » защищён: onSelect ловит попытку курсора влезть в позиции 0..2 и мягко переставляет на 3.
This commit is contained in:
nurdotnet 2026-05-03 17:01:20 +05:00
parent a7130f3116
commit 3beaec214a
2 changed files with 138 additions and 57 deletions

View file

@ -1,4 +1,6 @@
import { useMemo, useRef, useEffect, type InputHTMLAttributes } from 'react' import { useMemo, useRef, type InputHTMLAttributes } from 'react'
const PREFIX_LEN = 3
function extractDigits(value: string): string { function extractDigits(value: string): string {
if (!value) return '' if (!value) return ''
@ -15,52 +17,93 @@ function formatLocal(d: string): string {
return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6, 8)} ${d.slice(8, 10)}` return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6, 8)} ${d.slice(8, 10)}`
} }
function cursorToDigitIdx(cursor: number, display: string): number {
if (cursor <= PREFIX_LEN) return 0
let idx = 0
for (let i = PREFIX_LEN; i < cursor && i < display.length; i++) {
if (display[i] !== ' ') idx++
}
return idx
}
function digitIdxToCursor(idx: number, digits: string): number {
if (idx <= 0) return PREFIX_LEN
const display = `+7 ${formatLocal(digits)}`
let count = 0
for (let i = PREFIX_LEN; i < display.length; i++) {
if (display[i] !== ' ') {
count++
if (count === idx) {
const next = i + 1
return next < display.length && display[next] === ' ' ? next + 1 : next
}
}
}
return display.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
} }
/** Полностью контролируемый ввод телефона KZ. См. food-market.web/components/PhoneInput.tsx. */ /** PhoneInput для public — см. food-market.web/components/PhoneInput.tsx. */
export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) { export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) {
const ref = useRef<HTMLInputElement>(null) const ref = useRef<HTMLInputElement>(null)
const digits = useMemo(() => extractDigits(value), [value]) const digits = useMemo(() => extractDigits(value), [value])
const display = `+7 ${formatLocal(digits)}` const display = `+7 ${formatLocal(digits)}`
useEffect(() => { const commit = (newDigits: string, digitIdx: number) => {
onChange(newDigits.length > 0 ? `+7${newDigits}` : '')
requestAnimationFrame(() => {
const el = ref.current const el = ref.current
if (!el || document.activeElement !== el) return if (!el) return
const end = display.length const pos = digitIdxToCursor(digitIdx, newDigits)
el.setSelectionRange(end, end) el.setSelectionRange(pos, pos)
}, [display]) })
}
const setDigits = (d: string) => onChange(d.length > 0 ? `+7${d}` : '')
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.metaKey || e.ctrlKey || e.altKey) return if (e.metaKey || e.ctrlKey || e.altKey) return
if (['Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Escape'].includes(e.key)) return if (['Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Escape'].includes(e.key)) return
e.preventDefault() e.preventDefault()
if (e.key === 'Backspace' || e.key === 'Delete') { const rawCursor = e.currentTarget.selectionStart ?? PREFIX_LEN
if (digits.length > 0) setDigits(digits.slice(0, -1)) const idx = cursorToDigitIdx(Math.max(PREFIX_LEN, rawCursor), display)
if (e.key === 'Backspace') {
if (idx > 0) commit(digits.slice(0, idx - 1) + digits.slice(idx), idx - 1)
return return
} }
if (/^\d$/.test(e.key) && digits.length < 10) setDigits(digits + e.key) if (e.key === 'Delete') {
if (idx < digits.length) commit(digits.slice(0, idx) + digits.slice(idx + 1), idx)
return
}
if (/^\d$/.test(e.key) && digits.length < 10) {
commit((digits.slice(0, idx) + e.key + digits.slice(idx)).slice(0, 10), idx + 1)
}
} }
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => { const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault() e.preventDefault()
setDigits(extractDigits(e.clipboardData.getData('text'))) const newDigits = extractDigits(e.clipboardData.getData('text'))
commit(newDigits, newDigits.length)
}
const guardCursor = (el: HTMLInputElement) => {
const start = el.selectionStart ?? 0
const end = el.selectionEnd ?? 0
if (start < PREFIX_LEN || end < PREFIX_LEN) {
el.setSelectionRange(Math.max(PREFIX_LEN, start), Math.max(PREFIX_LEN, end))
}
} }
const handleFocus = () => { const handleFocus = () => {
const el = ref.current const el = ref.current
if (!el) return if (!el) return
requestAnimationFrame(() => el.setSelectionRange(display.length, display.length)) requestAnimationFrame(() => guardCursor(el))
} }
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => { const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => {
const el = e.currentTarget guardCursor(e.currentTarget)
const end = display.length
if ((el.selectionStart ?? 0) !== end) el.setSelectionRange(end, end)
} }
return ( return (
@ -74,7 +117,7 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
onFocus={handleFocus} onFocus={handleFocus}
onClick={handleClick} onSelect={handleSelect}
disabled={disabled} disabled={disabled}
placeholder="+7 700 123 45 67" placeholder="+7 700 123 45 67"
className={className} className={className}

View file

@ -1,9 +1,10 @@
import { useMemo, useRef, useEffect, type InputHTMLAttributes } from 'react' import { useMemo, useRef, type InputHTMLAttributes } 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'
/** Из любого формата (включая каноничное «+7XXXXXXXXXX») вытаскивает 10 цифр субскрайбера. */ const PREFIX_LEN = 3 // длина «+7 »
function extractDigits(value: string): string { function extractDigits(value: string): string {
if (!value) return '' if (!value) return ''
let d = value.replace(/\D/g, '') let d = value.replace(/\D/g, '')
@ -19,6 +20,35 @@ function formatLocal(d: string): string {
return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6, 8)} ${d.slice(8, 10)}` return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6, 8)} ${d.slice(8, 10)}`
} }
/** Сколько цифр стоит до позиции `cursor` в display-строке (после префикса). */
function cursorToDigitIdx(cursor: number, display: string): number {
if (cursor <= PREFIX_LEN) return 0
let idx = 0
for (let i = PREFIX_LEN; i < cursor && i < display.length; i++) {
if (display[i] !== ' ') idx++
}
return idx
}
/** Позиция курсора в display сразу ПОСЛЕ цифры с индексом `idx`. */
function digitIdxToCursor(idx: number, digits: string): number {
if (idx <= 0) return PREFIX_LEN
const display = `+7 ${formatLocal(digits)}`
let count = 0
for (let i = PREFIX_LEN; i < display.length; i++) {
if (display[i] !== ' ') {
count++
if (count === idx) {
// Если за этой цифрой идёт пробел — курсор лучше поставить ПОСЛЕ него,
// чтобы следующий ввод визуально продолжался без скачка через формат.
const next = i + 1
return next < display.length && display[next] === ' ' ? next + 1 : next
}
}
}
return display.length
}
interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> { interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
/** Каноничное значение в формате «+7XXXXXXXXXX» либо пустая строка. */ /** Каноничное значение в формате «+7XXXXXXXXXX» либо пустая строка. */
value: string value: string
@ -26,67 +56,75 @@ interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'v
onChange: (value: string) => void onChange: (value: string) => void
} }
/** Поле ввода телефона Казахстана с зашитым префиксом «+7 ». Полностью /** Поле ввода телефона Казахстана. Префикс «+7 » зашит и не редактируется,
* контролируемый ввод: все клавиши обрабатываются вручную, а display всегда * во всём остальном поле работает как обычный input: курсор можно ставить
* синхронизирован с цифрами. Backspace удаляет последнюю цифру (где бы ни * куда угодно (но не в префикс), Backspace удаляет цифру перед курсором,
* стоял курсор), цифровые клавиши добавляют (пока их меньше 10), остальные * Delete после курсора, цифровая клавиша вставляет в позицию курсора.
* символы игнорируются. Paste нормализуется через onPaste. */ * Не-цифры физически не вводятся, paste нормализуется. */
export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) { export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) {
const ref = useRef<HTMLInputElement>(null) const ref = useRef<HTMLInputElement>(null)
const digits = useMemo(() => extractDigits(value), [value]) const digits = useMemo(() => extractDigits(value), [value])
const display = `+7 ${formatLocal(digits)}` const display = `+7 ${formatLocal(digits)}`
// Курсор всегда держим в конце текущего display'а — так юзеру очевидно, const commit = (newDigits: string, digitIdx: number) => {
// куда уйдёт следующая цифра, и не возникает попыток печатать в середине. onChange(newDigits.length > 0 ? `+7${newDigits}` : '')
useEffect(() => { requestAnimationFrame(() => {
const el = ref.current const el = ref.current
if (!el || document.activeElement !== el) return if (!el) return
const end = display.length const pos = digitIdxToCursor(digitIdx, newDigits)
el.setSelectionRange(end, end) el.setSelectionRange(pos, pos)
}, [display]) })
const setDigits = (d: string) => {
onChange(d.length > 0 ? `+7${d}` : '')
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Системные шорткаты и навигация — пропускаем без вмешательства. // Шорткаты и навигация — мимо.
if (e.metaKey || e.ctrlKey || e.altKey) return if (e.metaKey || e.ctrlKey || e.altKey) return
if (['Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Escape'].includes(e.key)) return if (['Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Escape'].includes(e.key)) return
// Всё остальное — обрабатываем вручную, нативный ввод запрещаем.
e.preventDefault() e.preventDefault()
if (e.key === 'Backspace' || e.key === 'Delete') { const el = e.currentTarget
if (digits.length > 0) setDigits(digits.slice(0, -1)) const rawCursor = el.selectionStart ?? PREFIX_LEN
// Курсор не может быть в префиксе — для расчётов клампим на digit-зону.
const idx = cursorToDigitIdx(Math.max(PREFIX_LEN, rawCursor), display)
if (e.key === 'Backspace') {
if (idx > 0) commit(digits.slice(0, idx - 1) + digits.slice(idx), idx - 1)
return return
} }
if (e.key === 'Delete') {
if (/^\d$/.test(e.key) && digits.length < 10) { if (idx < digits.length) commit(digits.slice(0, idx) + digits.slice(idx + 1), idx)
setDigits(digits + e.key) return
} }
// Любой другой ключ — просто игнорируем (preventDefault уже сделан). if (/^\d$/.test(e.key) && digits.length < 10) {
commit((digits.slice(0, idx) + e.key + digits.slice(idx)).slice(0, 10), idx + 1)
}
// Любой другой символ — игнор.
} }
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => { const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault() e.preventDefault()
const pasted = e.clipboardData.getData('text') const newDigits = extractDigits(e.clipboardData.getData('text'))
setDigits(extractDigits(pasted)) commit(newDigits, newDigits.length)
}
// Не даём поставить курсор в «+7 » префикс — мягко двигаем на первую digit-позицию.
const guardCursor = (el: HTMLInputElement) => {
const start = el.selectionStart ?? 0
const end = el.selectionEnd ?? 0
if (start < PREFIX_LEN || end < PREFIX_LEN) {
el.setSelectionRange(Math.max(PREFIX_LEN, start), Math.max(PREFIX_LEN, end))
}
} }
const handleFocus = () => { const handleFocus = () => {
const el = ref.current const el = ref.current
if (!el) return if (!el) return
requestAnimationFrame(() => { requestAnimationFrame(() => guardCursor(el))
const end = display.length
el.setSelectionRange(end, end)
})
} }
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => { const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => {
const el = e.currentTarget guardCursor(e.currentTarget)
const end = display.length
if ((el.selectionStart ?? 0) !== end) el.setSelectionRange(end, end)
} }
return ( return (
@ -100,7 +138,7 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste} onPaste={handlePaste}
onFocus={handleFocus} onFocus={handleFocus}
onClick={handleClick} onSelect={handleSelect}
disabled={disabled} disabled={disabled}
placeholder="+7 700 123 45 67" placeholder="+7 700 123 45 67"
className={cn(inputClass, className)} className={cn(inputClass, className)}