diff --git a/src/food-market.public/src/components/PhoneInput.tsx b/src/food-market.public/src/components/PhoneInput.tsx index 0b58aef..e04fc55 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 } 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, '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(null) const digits = useMemo(() => extractDigits(value), [value]) - const display = `+7 ${formatLocal(digits)}`.trimEnd() + const display = `+7 ${formatLocal(digits)}` - const handleChange = (e: React.ChangeEvent) => { - 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) => { 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) => { + 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) => { 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} diff --git a/src/food-market.web/src/components/PhoneInput.tsx b/src/food-market.web/src/components/PhoneInput.tsx index df05d76..5e11b83 100644 --- a/src/food-market.web/src/components/PhoneInput.tsx +++ b/src/food-market.web/src/components/PhoneInput.tsx @@ -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, '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(null) - const digits = useMemo(() => extractDigits(value), [value]) - const display = `+7 ${formatLocal(digits)}`.trimEnd() + const display = `+7 ${formatLocal(digits)}` - const handleChange = (e: React.ChangeEvent) => { - 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) => { - // 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) => { - // При первом фокусе на пустом поле — курсор после "+7 ". - if (digits.length === 0) { - requestAnimationFrame(() => { - const el = ref.current - if (!el) return - el.setSelectionRange(3, 3) - }) - } + const handlePaste = (e: React.ClipboardEvent) => { + 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) => { 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}