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