diff --git a/src/food-market.public/src/components/PhoneInput.tsx b/src/food-market.public/src/components/PhoneInput.tsx index cb2bee4..70642c4 100644 --- a/src/food-market.public/src/components/PhoneInput.tsx +++ b/src/food-market.public/src/components/PhoneInput.tsx @@ -1,45 +1,22 @@ -import { useMemo, useRef, type InputHTMLAttributes } from 'react' +import { useMemo, useRef, type InputHTMLAttributes, type FormEvent } from 'react' -const PREFIX_LEN = 3 +const PREFIX = '+7 7' +const PREFIX_LEN = 4 -function extractDigits(value: string): string { - if (!value) return '' - let d = value.replace(/\D/g, '') - if (d.length === 11 && (d[0] === '7' || d[0] === '8')) d = d.slice(1) - return d.slice(0, 10) +function rawToUserDigits(raw: string): string { + let d = raw.replace(/\D/g, '') + if (d[0] === '8') d = d.slice(1) + if (d[0] === '7') d = d.slice(1) + if (d[0] === '7') d = d.slice(1) + return d.slice(0, 9) } -function formatLocal(d: string): string { - if (d.length === 0) return '' - if (d.length <= 3) return d - if (d.length <= 6) return `${d.slice(0, 3)} ${d.slice(3)}` - if (d.length <= 8) return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6)}` - 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 +function userDigitsToDisplay(d: string): string { + if (d.length === 0) return PREFIX + if (d.length <= 2) return `${PREFIX}${d}` + if (d.length <= 5) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2)}` + if (d.length <= 7) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5)}` + return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}` } interface PhoneInputProps extends Omit, 'value' | 'onChange' | 'type'> { @@ -50,42 +27,19 @@ interface PhoneInputProps extends Omit, 'v /** 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)}` + const userDigits = useMemo(() => rawToUserDigits(value), [value]) + const display = userDigitsToDisplay(userDigits) - 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() - 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 (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 handleBeforeInput = (e: FormEvent) => { + const native = e.nativeEvent as InputEvent + if (native.data && /\D/.test(native.data)) { + e.preventDefault() } } - const handlePaste = (e: React.ClipboardEvent) => { - e.preventDefault() - const newDigits = extractDigits(e.clipboardData.getData('text')) - commit(newDigits, newDigits.length) + const handleChange = (e: React.ChangeEvent) => { + const newDigits = rawToUserDigits(e.target.value) + onChange(newDigits.length > 0 ? `+77${newDigits}` : '') } const guardCursor = (el: HTMLInputElement) => { @@ -96,16 +50,13 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph } } + const handleSelect = (e: React.SyntheticEvent) => guardCursor(e.currentTarget) const handleFocus = () => { const el = ref.current if (!el) return requestAnimationFrame(() => guardCursor(el)) } - const handleSelect = (e: React.SyntheticEvent) => { - guardCursor(e.currentTarget) - } - return ( { /* no-op */ }} - onKeyDown={handleKeyDown} - onPaste={handlePaste} - onFocus={handleFocus} + onBeforeInput={handleBeforeInput} + onChange={handleChange} onSelect={handleSelect} + onFocus={handleFocus} disabled={disabled} - placeholder="+7 700 123 45 67" + placeholder={PREFIX} className={className} {...rest} /> diff --git a/src/food-market.web/src/components/PhoneInput.tsx b/src/food-market.web/src/components/PhoneInput.tsx index 99c5c22..296395a 100644 --- a/src/food-market.web/src/components/PhoneInput.tsx +++ b/src/food-market.web/src/components/PhoneInput.tsx @@ -1,114 +1,68 @@ -import { useMemo, useRef, type InputHTMLAttributes } from 'react' +import { useMemo, useRef, type InputHTMLAttributes, type FormEvent } 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' -const PREFIX_LEN = 3 // длина «+7 » +const PREFIX = '+7 7' // Залочено: «+», «7», пробел, «7» (мобильный код KZ). +const PREFIX_LEN = PREFIX.length // 4 -function extractDigits(value: string): string { - if (!value) return '' - let d = value.replace(/\D/g, '') - if (d.length === 11 && (d[0] === '7' || d[0] === '8')) d = d.slice(1) - return d.slice(0, 10) +/** Извлекает 9 пользовательских цифр из любой формы значения (canonical + * «+77XXXXXXXXX», display «+7 7XX XXX XX XX», raw paste «8 707…», и т.д.). + * Снимает по одной ведущей «8», «7», «7» — это код страны и мобильный + * префикс KZ, оба зашиты в наш PREFIX. */ +function rawToUserDigits(raw: string): string { + let d = raw.replace(/\D/g, '') + if (d[0] === '8') d = d.slice(1) + if (d[0] === '7') d = d.slice(1) + if (d[0] === '7') d = d.slice(1) + return d.slice(0, 9) } -function formatLocal(d: string): string { - if (d.length === 0) return '' - if (d.length <= 3) return d - if (d.length <= 6) return `${d.slice(0, 3)} ${d.slice(3)}` - if (d.length <= 8) return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6)}` - 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 +/** Display из 9 пользовательских цифр. Формат: «+7 7XX XXX XX XX» — после + * залоченного «+7 7» идут 2+3+2+2 = 9 цифр через пробелы. */ +function userDigitsToDisplay(d: string): string { + if (d.length === 0) return PREFIX + if (d.length <= 2) return `${PREFIX}${d}` + if (d.length <= 5) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2)}` + if (d.length <= 7) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5)}` + return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}` } interface PhoneInputProps extends Omit, 'value' | 'onChange' | 'type'> { - /** Каноничное значение в формате «+7XXXXXXXXXX» либо пустая строка. */ + /** Каноничное значение «+77XXXXXXXXX» либо пустая строка. */ value: string - /** Возвращает каноничное «+7XXXXXXXXXX» или пустую строку. */ onChange: (value: string) => void } -/** Поле ввода телефона Казахстана. Префикс «+7 » зашит и не редактируется, - * во всём остальном поле работает как обычный input: курсор можно ставить - * куда угодно (но не в префикс), Backspace удаляет цифру перед курсором, - * Delete — после курсора, цифровая клавиша вставляет в позицию курсора. - * Не-цифры физически не вводятся, paste нормализуется. */ +/** Поле ввода телефона Казахстана. Префикс «+7 7» (4 символа) залочен — + * курсор не пускается в позиции 0..3, выделение клампится на digit-зону. + * В остальном работает как обычный input: native selection, backspace, + * click, Cmd+A, paste, drag — всё нативное. Единственное ограничение — + * не-цифры блокируются через onBeforeInput до того как попасть в value. */ export function PhoneInput({ value, onChange, className, disabled, ...rest }: PhoneInputProps) { const ref = useRef(null) - const digits = useMemo(() => extractDigits(value), [value]) - const display = `+7 ${formatLocal(digits)}` + const userDigits = useMemo(() => rawToUserDigits(value), [value]) + const display = userDigitsToDisplay(userDigits) - 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) - }) + // Блокируем вставку не-цифр (типинг + paste + IME). data === null для + // deleteContentBackward и т.п. — там не вмешиваемся, пусть браузер удаляет. + const handleBeforeInput = (e: FormEvent) => { + const native = e.nativeEvent as InputEvent + if (native.data && /\D/.test(native.data)) { + e.preventDefault() + } } - 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() - - 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 (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) - } - // Любой другой символ — игнор. + // После любого нативного редактирования (печать/удаление/paste/drag-drop) + // нормализуем значение. Если юзер случайно стер часть префикса — он + // автоматически восстановится при ре-рендере (display всегда начинается с PREFIX). + const handleChange = (e: React.ChangeEvent) => { + const newDigits = rawToUserDigits(e.target.value) + onChange(newDigits.length > 0 ? `+77${newDigits}` : '') } - const handlePaste = (e: React.ClipboardEvent) => { - e.preventDefault() - const newDigits = extractDigits(e.clipboardData.getData('text')) - commit(newDigits, newDigits.length) - } - - // Не даём поставить курсор в «+7 » префикс — мягко двигаем на первую digit-позицию. + // Не пускаем курсор/selection в префикс. onSelect ловит любые попытки + // (клик, стрелки, Cmd+A) и мягко двигает на digit-зону. const guardCursor = (el: HTMLInputElement) => { const start = el.selectionStart ?? 0 const end = el.selectionEnd ?? 0 @@ -117,16 +71,13 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph } } + const handleSelect = (e: React.SyntheticEvent) => guardCursor(e.currentTarget) const handleFocus = () => { const el = ref.current if (!el) return requestAnimationFrame(() => guardCursor(el)) } - const handleSelect = (e: React.SyntheticEvent) => { - guardCursor(e.currentTarget) - } - return ( { /* no-op: ввод управляется через onKeyDown/onPaste */ }} - onKeyDown={handleKeyDown} - onPaste={handlePaste} - onFocus={handleFocus} + onBeforeInput={handleBeforeInput} + onChange={handleChange} onSelect={handleSelect} + onFocus={handleFocus} disabled={disabled} - placeholder="+7 700 123 45 67" + placeholder={PREFIX} className={cn(inputClass, className)} {...rest} />