fix(phone): полностью переписать на простую модель — цифры как single source of truth
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m5s
CI / Web (React + Vite) (push) Successful in 38s
Docker Public / Build + push Public (push) Successful in 27s
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

Предыдущие итерации пытались парсить промежуточный raw input от браузера и постоянно ловили баги: курсор не там → парсер путал префикс с введёнными цифрами; Backspace на странной позиции → блокировался не там где надо.

Новая модель: фронт полностью контролирует input, нативный ввод запрещён. Все клавиши обрабатываются вручную в onKeyDown:
- Цифра → если набрано <10, добавить в конец.
- Backspace/Delete → если есть цифры, удалить последнюю.
- Любой другой символ → игнор (preventDefault).
- Шорткаты (Cmd/Ctrl+что-то), стрелки, Tab, Enter, Esc → пропускаем.

Курсор автоматически держим в конце display'а (после фокуса/клика/изменения). Paste обрабатывается отдельно через onPaste с нормализацией. onChange = no-op (React-warning заглушка).

Бенефиты: Backspace теперь всегда удаляет цифру, без оглядки на позицию. Не-цифры физически невозможно ввести. Префикс «+7 » никогда не повреждается.
This commit is contained in:
nurdotnet 2026-05-03 16:56:52 +05:00
parent 16fe7580af
commit a7130f3116
2 changed files with 72 additions and 94 deletions

View file

@ -1,4 +1,4 @@
import { useMemo, useRef, type InputHTMLAttributes } from 'react' import { useMemo, useRef, useEffect, type InputHTMLAttributes } from 'react'
function extractDigits(value: string): string { function extractDigits(value: string): string {
if (!value) return '' if (!value) return ''
@ -7,17 +7,6 @@ function extractDigits(value: string): string {
return d.slice(0, 10) return d.slice(0, 10)
} }
/** Парсит сырой ввод инпута, отбрасывая префикс «+7 ». Без этого «7» из
* префикса попадает в подсчёт и при нажатии не-цифры появляется лишняя «7». */
function parseRawInput(raw: string): string {
if (raw.startsWith('+7 ')) raw = raw.slice(3)
else if (raw.startsWith('+7')) raw = raw.slice(2)
else if (raw.startsWith('+')) raw = raw.slice(1)
let d = raw.replace(/\D/g, '')
if (d.length === 11 && (d[0] === '7' || d[0] === '8')) d = d.slice(1)
return d.slice(0, 10)
}
function formatLocal(d: string): string { function formatLocal(d: string): string {
if (d.length === 0) return '' if (d.length === 0) return ''
if (d.length <= 3) return d if (d.length <= 3) return d
@ -31,46 +20,47 @@ interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'v
onChange: (value: string) => void onChange: (value: string) => void
} }
/** Телефон Казахстана с зашитым префиксом "+7 ". См. food-market.web/components/PhoneInput.tsx. */ /** Полностью контролируемый ввод телефона KZ. См. 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)}`.trimEnd() const display = `+7 ${formatLocal(digits)}`
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { useEffect(() => {
const d = parseRawInput(e.target.value) const el = ref.current
onChange(d.length > 0 ? `+7${d}` : '') if (!el || document.activeElement !== el) return
} const end = display.length
el.setSelectionRange(end, end)
}, [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
const el = e.currentTarget e.preventDefault()
const start = el.selectionStart ?? 0 if (e.key === 'Backspace' || e.key === 'Delete') {
const end = el.selectionEnd ?? 0 if (digits.length > 0) setDigits(digits.slice(0, -1))
if (e.key === 'Backspace') {
if (start <= 3 && end === start) e.preventDefault()
return return
} }
if (e.key === 'Delete') { if (/^\d$/.test(e.key) && digits.length < 10) setDigits(digits + e.key)
if (start < 3) e.preventDefault()
return
} }
// Только цифры — иначе появляется фантомная «7» из префикса.
if (!/^\d$/.test(e.key)) e.preventDefault() const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
setDigits(extractDigits(e.clipboardData.getData('text')))
} }
const handleFocus = () => { const handleFocus = () => {
if (digits.length === 0) { const el = ref.current
requestAnimationFrame(() => { if (!el) return
ref.current?.setSelectionRange(3, 3) requestAnimationFrame(() => el.setSelectionRange(display.length, display.length))
})
}
} }
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => { const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
const el = e.currentTarget const el = e.currentTarget
if ((el.selectionStart ?? 0) < 3) el.setSelectionRange(3, 3) const end = display.length
if ((el.selectionStart ?? 0) !== end) el.setSelectionRange(end, end)
} }
return ( return (
@ -80,8 +70,9 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
inputMode="tel" inputMode="tel"
autoComplete="tel" autoComplete="tel"
value={display} value={display}
onChange={handleChange} onChange={() => { /* no-op */ }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={handleFocus} onFocus={handleFocus}
onClick={handleClick} onClick={handleClick}
disabled={disabled} disabled={disabled}

View file

@ -1,9 +1,9 @@
import { useMemo, useRef, type InputHTMLAttributes } from 'react' import { useMemo, useRef, useEffect, 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'
/** Извлекает 10 цифр KZ-номера (после +7) из каноничного значения «+7XXXXXXXXXX». */ /** Из любого формата (включая каноничное «+7XXXXXXXXXX») вытаскивает 10 цифр субскрайбера. */
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, '')
@ -11,22 +11,6 @@ function extractDigits(value: string): string {
return d.slice(0, 10) return d.slice(0, 10)
} }
/** Парсит сырой ввод инпута (то что показано на экране, например «+7 700 1»)
* и достаёт реально введённые юзером цифры (после «+7 »). Критично: «+7 »
* префикс отбрасывается ДО извлечения цифр, иначе «7» из префикса попадает
* в подсчёт и при нажатии не-цифры на пустом поле появляется «7». */
function parseRawInput(raw: string): string {
// Снимаем именно наш префикс — в любом из трёх возможных промежуточных видов.
if (raw.startsWith('+7 ')) raw = raw.slice(3)
else if (raw.startsWith('+7')) raw = raw.slice(2)
else if (raw.startsWith('+')) raw = raw.slice(1)
let d = raw.replace(/\D/g, '')
// Кейс paste «8 700 …» или «7 700 …» без нашего префикса.
if (d.length === 11 && (d[0] === '7' || d[0] === '8')) d = d.slice(1)
return d.slice(0, 10)
}
/** Формат "XXX XXX XX XX" по 10 цифрам субскрайбера. */
function formatLocal(d: string): string { function formatLocal(d: string): string {
if (d.length === 0) return '' if (d.length === 0) return ''
if (d.length <= 3) return d if (d.length <= 3) return d
@ -36,71 +20,73 @@ function formatLocal(d: string): string {
} }
interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> { interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
/** Каноничное значение в формате "+7XXXXXXXXXX" либо пустая строка. */ /** Каноничное значение в формате «+7XXXXXXXXXX» либо пустая строка. */
value: string value: string
/** Возвращает каноничное "+7XXXXXXXXXX" если введено 10 цифр, иначе пустую строку. */ /** Возвращает каноничное «+7XXXXXXXXXX» или пустую строку. */
onChange: (value: string) => void onChange: (value: string) => void
} }
/** Поле ввода телефона Казахстана. Префикс "+7 " зашит и не удаляется, /** Поле ввода телефона Казахстана с зашитым префиксом «+7 ». Полностью
* принимаются только цифры (буквы и спецсимволы блокируются), при вводе * контролируемый ввод: все клавиши обрабатываются вручную, а display всегда
* автоматически форматируется как "+7 7XX XXX XX XX". На onChange наружу * синхронизирован с цифрами. Backspace удаляет последнюю цифру (где бы ни
* отдаётся каноничное "+7XXXXXXXXXX" (если все 10 цифр введены) или "". * стоял курсор), цифровые клавиши добавляют (пока их меньше 10), остальные
* Поддерживает paste произвольного формата (включая "8 …" и "+7 …"). */ * символы игнорируются. Paste нормализуется через onPaste. */
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)}`.trimEnd() const display = `+7 ${formatLocal(digits)}`
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { // Курсор всегда держим в конце текущего display'а — так юзеру очевидно,
const d = parseRawInput(e.target.value) // куда уйдёт следующая цифра, и не возникает попыток печатать в середине.
useEffect(() => {
const el = ref.current
if (!el || document.activeElement !== el) return
const end = display.length
el.setSelectionRange(end, end)
}, [display])
const setDigits = (d: string) => {
onChange(d.length > 0 ? `+7${d}` : '') onChange(d.length > 0 ? `+7${d}` : '')
} }
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
// Cmd/Ctrl + что-то — copy/paste/select-all и прочие шорткаты, пропускаем. // Системные шорткаты и навигация — пропускаем без вмешательства.
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
const el = e.currentTarget // Всё остальное — обрабатываем вручную, нативный ввод запрещаем.
const start = el.selectionStart ?? 0
const end = el.selectionEnd ?? 0
// Backspace/Delete — разрешаем, но не даём удалить префикс «+7 » (3 символа).
if (e.key === 'Backspace') {
if (start <= 3 && end === start) e.preventDefault()
return
}
if (e.key === 'Delete') {
if (start < 3) e.preventDefault()
return
}
// Дальше — только цифры. Любой другой символ блокируем, чтобы он не
// попал в input и не запутал парсер. Без этого нажатие буквы при курсоре
// в любом месте ломает раскладку и приводит к появлению фантомной «7».
if (!/^\d$/.test(e.key)) {
e.preventDefault() e.preventDefault()
}
if (e.key === 'Backspace' || e.key === 'Delete') {
if (digits.length > 0) setDigits(digits.slice(0, -1))
return
} }
const handleFocus = (e: React.FocusEvent<HTMLInputElement>) => { if (/^\d$/.test(e.key) && digits.length < 10) {
// При первом фокусе на пустом поле — курсор после "+7 ". setDigits(digits + e.key)
if (digits.length === 0) { }
requestAnimationFrame(() => { // Любой другой ключ — просто игнорируем (preventDefault уже сделан).
}
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pasted = e.clipboardData.getData('text')
setDigits(extractDigits(pasted))
}
const handleFocus = () => {
const el = ref.current const el = ref.current
if (!el) return if (!el) return
el.setSelectionRange(3, 3) requestAnimationFrame(() => {
const end = display.length
el.setSelectionRange(end, end)
}) })
} }
}
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => { const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
const el = e.currentTarget const el = e.currentTarget
// Если кликнули внутрь "+7 " префикса — переставляем курсор после него. const end = display.length
if ((el.selectionStart ?? 0) < 3) el.setSelectionRange(3, 3) if ((el.selectionStart ?? 0) !== end) el.setSelectionRange(end, end)
} }
return ( return (
@ -110,8 +96,9 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
inputMode="tel" inputMode="tel"
autoComplete="tel" autoComplete="tel"
value={display} value={display}
onChange={handleChange} onChange={() => { /* no-op: ввод управляется через onKeyDown/onPaste */ }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={handleFocus} onFocus={handleFocus}
onClick={handleClick} onClick={handleClick}
disabled={disabled} disabled={disabled}