diff --git a/src/food-market.public/src/components/PhoneInput.tsx b/src/food-market.public/src/components/PhoneInput.tsx index e04fc55..cb2bee4 100644 --- a/src/food-market.public/src/components/PhoneInput.tsx +++ b/src/food-market.public/src/components/PhoneInput.tsx @@ -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 { 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)}` } +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, 'value' | 'onChange' | 'type'> { value: string 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) { const ref = useRef(null) const digits = useMemo(() => extractDigits(value), [value]) const display = `+7 ${formatLocal(digits)}` - 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 commit = (newDigits: string, digitIdx: number) => { + onChange(newDigits.length > 0 ? `+7${newDigits}` : '') + requestAnimationFrame(() => { + const el = ref.current + if (!el) return + const pos = digitIdxToCursor(digitIdx, newDigits) + el.setSelectionRange(pos, pos) + }) + } 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 e.preventDefault() - if (e.key === 'Backspace' || e.key === 'Delete') { - if (digits.length > 0) setDigits(digits.slice(0, -1)) + const rawCursor = e.currentTarget.selectionStart ?? PREFIX_LEN + 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 } - 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) => { 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 el = ref.current if (!el) return - requestAnimationFrame(() => el.setSelectionRange(display.length, display.length)) + requestAnimationFrame(() => guardCursor(el)) } - const handleClick = (e: React.MouseEvent) => { - const el = e.currentTarget - const end = display.length - if ((el.selectionStart ?? 0) !== end) el.setSelectionRange(end, end) + const handleSelect = (e: React.SyntheticEvent) => { + guardCursor(e.currentTarget) } return ( @@ -74,7 +117,7 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph onKeyDown={handleKeyDown} onPaste={handlePaste} onFocus={handleFocus} - onClick={handleClick} + onSelect={handleSelect} disabled={disabled} placeholder="+7 700 123 45 67" className={className} diff --git a/src/food-market.web/src/components/PhoneInput.tsx b/src/food-market.web/src/components/PhoneInput.tsx index 5e11b83..99c5c22 100644 --- a/src/food-market.web/src/components/PhoneInput.tsx +++ b/src/food-market.web/src/components/PhoneInput.tsx @@ -1,9 +1,10 @@ -import { useMemo, useRef, useEffect, type InputHTMLAttributes } from 'react' +import { useMemo, useRef, 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' -/** Из любого формата (включая каноничное «+7XXXXXXXXXX») вытаскивает 10 цифр субскрайбера. */ +const PREFIX_LEN = 3 // длина «+7 » + function extractDigits(value: string): string { if (!value) return '' 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)}` } +/** Сколько цифр стоит до позиции `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, 'value' | 'onChange' | 'type'> { /** Каноничное значение в формате «+7XXXXXXXXXX» либо пустая строка. */ value: string @@ -26,67 +56,75 @@ interface PhoneInputProps extends Omit, 'v onChange: (value: string) => void } -/** Поле ввода телефона Казахстана с зашитым префиксом «+7 ». Полностью - * контролируемый ввод: все клавиши обрабатываются вручную, а display всегда - * синхронизирован с цифрами. Backspace удаляет последнюю цифру (где бы ни - * стоял курсор), цифровые клавиши добавляют (пока их меньше 10), остальные - * символы игнорируются. Paste нормализуется через onPaste. */ +/** Поле ввода телефона Казахстана. Префикс «+7 » зашит и не редактируется, + * во всём остальном поле работает как обычный input: курсор можно ставить + * куда угодно (но не в префикс), Backspace удаляет цифру перед курсором, + * Delete — после курсора, цифровая клавиша вставляет в позицию курсора. + * Не-цифры физически не вводятся, paste нормализуется. */ export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) { const ref = useRef(null) const digits = useMemo(() => extractDigits(value), [value]) const display = `+7 ${formatLocal(digits)}` - // Курсор всегда держим в конце текущего 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 commit = (newDigits: string, digitIdx: number) => { + onChange(newDigits.length > 0 ? `+7${newDigits}` : '') + requestAnimationFrame(() => { + const el = ref.current + if (!el) return + const pos = digitIdxToCursor(digitIdx, newDigits) + el.setSelectionRange(pos, pos) + }) } 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 - // Всё остальное — обрабатываем вручную, нативный ввод запрещаем. e.preventDefault() - if (e.key === 'Backspace' || e.key === 'Delete') { - if (digits.length > 0) setDigits(digits.slice(0, -1)) + const el = e.currentTarget + 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 } - - 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 } - // Любой другой ключ — просто игнорируем (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) => { e.preventDefault() - const pasted = e.clipboardData.getData('text') - setDigits(extractDigits(pasted)) + const newDigits = extractDigits(e.clipboardData.getData('text')) + 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 el = ref.current if (!el) return - requestAnimationFrame(() => { - const end = display.length - el.setSelectionRange(end, end) - }) + requestAnimationFrame(() => guardCursor(el)) } - const handleClick = (e: React.MouseEvent) => { - const el = e.currentTarget - const end = display.length - if ((el.selectionStart ?? 0) !== end) el.setSelectionRange(end, end) + const handleSelect = (e: React.SyntheticEvent) => { + guardCursor(e.currentTarget) } return ( @@ -100,7 +138,7 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph onKeyDown={handleKeyDown} onPaste={handlePaste} onFocus={handleFocus} - onClick={handleClick} + onSelect={handleSelect} disabled={disabled} placeholder="+7 700 123 45 67" className={cn(inputClass, className)}