fix(phone): нативное редактирование, фильтр не-цифр через onBeforeInput
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m7s
CI / Web (React + Vite) (push) Successful in 41s
Docker Public / Build + push Public (push) Successful in 27s
Docker Web / Build + push Web (push) Successful in 33s
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 1m7s
CI / Web (React + Vite) (push) Successful in 41s
Docker Public / Build + push Public (push) Successful in 27s
Docker Web / Build + push Web (push) Successful in 33s
Docker Public / Deploy Public on stage (push) Successful in 10s
Docker Web / Deploy Web on stage (push) Successful in 11s
Прошлая попытка вручную обрабатывать каждую клавишу через onKeyDown ломала привычное поведение: selection + Backspace удалял только 1 цифру, нельзя было редактировать в середине, и т.д.
Новая модель совсем простая:
- Префикс «+7 7» (4 символа) залочен — onSelect клампит курсор/selection на digit-зону.
- Не-цифры блокируются через onBeforeInput (e.preventDefault если data содержит \D). Это покрывает и печать, и paste, и IME, и drag-drop.
- Всё остальное — нативное поведение браузера: selection + delete, click anywhere, arrow keys, Cmd+A, Cmd+V, drag-drop. После любого изменения onChange нормализует значение (rawToUserDigits извлекает 9 пользовательских цифр, отрезая залоченные «+7 7»).
- Display всегда «+7 7» + format(userDigits) — если юзер случайно стер часть префикса, она восстанавливается на ре-рендере.
Канон наружу: «+77XXXXXXXXX» (12 chars при полном номере), пустая строка если ничего не введено. Совместимо с существующим validatePhone (^77\d{9}$).
This commit is contained in:
parent
3beaec214a
commit
1264a91e2c
|
|
@ -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 {
|
function rawToUserDigits(raw: string): string {
|
||||||
if (!value) return ''
|
let d = raw.replace(/\D/g, '')
|
||||||
let d = value.replace(/\D/g, '')
|
if (d[0] === '8') d = d.slice(1)
|
||||||
if (d.length === 11 && (d[0] === '7' || d[0] === '8')) d = d.slice(1)
|
if (d[0] === '7') d = d.slice(1)
|
||||||
return d.slice(0, 10)
|
if (d[0] === '7') d = d.slice(1)
|
||||||
|
return d.slice(0, 9)
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatLocal(d: string): string {
|
function userDigitsToDisplay(d: string): string {
|
||||||
if (d.length === 0) return ''
|
if (d.length === 0) return PREFIX
|
||||||
if (d.length <= 3) return d
|
if (d.length <= 2) return `${PREFIX}${d}`
|
||||||
if (d.length <= 6) return `${d.slice(0, 3)} ${d.slice(3)}`
|
if (d.length <= 5) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2)}`
|
||||||
if (d.length <= 8) return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6)}`
|
if (d.length <= 7) return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5)}`
|
||||||
return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6, 8)} ${d.slice(8, 10)}`
|
return `${PREFIX}${d.slice(0, 2)} ${d.slice(2, 5)} ${d.slice(5, 7)} ${d.slice(7, 9)}`
|
||||||
}
|
|
||||||
|
|
||||||
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<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
|
interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
|
||||||
|
|
@ -50,42 +27,19 @@ interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'v
|
||||||
/** PhoneInput для public — см. food-market.web/components/PhoneInput.tsx. */
|
/** PhoneInput для public — см. 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 userDigits = useMemo(() => rawToUserDigits(value), [value])
|
||||||
const display = `+7 ${formatLocal(digits)}`
|
const display = userDigitsToDisplay(userDigits)
|
||||||
|
|
||||||
const commit = (newDigits: string, digitIdx: number) => {
|
const handleBeforeInput = (e: FormEvent<HTMLInputElement>) => {
|
||||||
onChange(newDigits.length > 0 ? `+7${newDigits}` : '')
|
const native = e.nativeEvent as InputEvent
|
||||||
requestAnimationFrame(() => {
|
if (native.data && /\D/.test(native.data)) {
|
||||||
const el = ref.current
|
e.preventDefault()
|
||||||
if (!el) return
|
|
||||||
const pos = digitIdxToCursor(digitIdx, newDigits)
|
|
||||||
el.setSelectionRange(pos, pos)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
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 handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
e.preventDefault()
|
const newDigits = rawToUserDigits(e.target.value)
|
||||||
const newDigits = extractDigits(e.clipboardData.getData('text'))
|
onChange(newDigits.length > 0 ? `+77${newDigits}` : '')
|
||||||
commit(newDigits, newDigits.length)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const guardCursor = (el: HTMLInputElement) => {
|
const guardCursor = (el: HTMLInputElement) => {
|
||||||
|
|
@ -96,16 +50,13 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => guardCursor(e.currentTarget)
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
requestAnimationFrame(() => guardCursor(el))
|
requestAnimationFrame(() => guardCursor(el))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => {
|
|
||||||
guardCursor(e.currentTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -113,13 +64,12 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
|
||||||
inputMode="tel"
|
inputMode="tel"
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
value={display}
|
value={display}
|
||||||
onChange={() => { /* no-op */ }}
|
onBeforeInput={handleBeforeInput}
|
||||||
onKeyDown={handleKeyDown}
|
onChange={handleChange}
|
||||||
onPaste={handlePaste}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
onFocus={handleFocus}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder="+7 700 123 45 67"
|
placeholder={PREFIX}
|
||||||
className={className}
|
className={className}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -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'
|
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'
|
||||||
|
|
||||||
const PREFIX_LEN = 3 // длина «+7 »
|
const PREFIX = '+7 7' // Залочено: «+», «7», пробел, «7» (мобильный код KZ).
|
||||||
|
const PREFIX_LEN = PREFIX.length // 4
|
||||||
|
|
||||||
function extractDigits(value: string): string {
|
/** Извлекает 9 пользовательских цифр из любой формы значения (canonical
|
||||||
if (!value) return ''
|
* «+77XXXXXXXXX», display «+7 7XX XXX XX XX», raw paste «8 707…», и т.д.).
|
||||||
let d = value.replace(/\D/g, '')
|
* Снимает по одной ведущей «8», «7», «7» — это код страны и мобильный
|
||||||
if (d.length === 11 && (d[0] === '7' || d[0] === '8')) d = d.slice(1)
|
* префикс KZ, оба зашиты в наш PREFIX. */
|
||||||
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 {
|
/** Display из 9 пользовательских цифр. Формат: «+7 7XX XXX XX XX» — после
|
||||||
if (d.length === 0) return ''
|
* залоченного «+7 7» идут 2+3+2+2 = 9 цифр через пробелы. */
|
||||||
if (d.length <= 3) return d
|
function userDigitsToDisplay(d: string): string {
|
||||||
if (d.length <= 6) return `${d.slice(0, 3)} ${d.slice(3)}`
|
if (d.length === 0) return PREFIX
|
||||||
if (d.length <= 8) return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6)}`
|
if (d.length <= 2) return `${PREFIX}${d}`
|
||||||
return `${d.slice(0, 3)} ${d.slice(3, 6)} ${d.slice(6, 8)} ${d.slice(8, 10)}`
|
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)}`
|
||||||
/** Сколько цифр стоит до позиции `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<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
|
interface PhoneInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'type'> {
|
||||||
/** Каноничное значение в формате «+7XXXXXXXXXX» либо пустая строка. */
|
/** Каноничное значение «+77XXXXXXXXX» либо пустая строка. */
|
||||||
value: string
|
value: string
|
||||||
/** Возвращает каноничное «+7XXXXXXXXXX» или пустую строку. */
|
|
||||||
onChange: (value: string) => void
|
onChange: (value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Поле ввода телефона Казахстана. Префикс «+7 » зашит и не редактируется,
|
/** Поле ввода телефона Казахстана. Префикс «+7 7» (4 символа) залочен —
|
||||||
* во всём остальном поле работает как обычный input: курсор можно ставить
|
* курсор не пускается в позиции 0..3, выделение клампится на digit-зону.
|
||||||
* куда угодно (но не в префикс), Backspace удаляет цифру перед курсором,
|
* В остальном работает как обычный input: native selection, backspace,
|
||||||
* Delete — после курсора, цифровая клавиша вставляет в позицию курсора.
|
* click, Cmd+A, paste, drag — всё нативное. Единственное ограничение —
|
||||||
* Не-цифры физически не вводятся, paste нормализуется. */
|
* не-цифры блокируются через onBeforeInput до того как попасть в value. */
|
||||||
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 userDigits = useMemo(() => rawToUserDigits(value), [value])
|
||||||
const display = `+7 ${formatLocal(digits)}`
|
const display = userDigitsToDisplay(userDigits)
|
||||||
|
|
||||||
const commit = (newDigits: string, digitIdx: number) => {
|
// Блокируем вставку не-цифр (типинг + paste + IME). data === null для
|
||||||
onChange(newDigits.length > 0 ? `+7${newDigits}` : '')
|
// deleteContentBackward и т.п. — там не вмешиваемся, пусть браузер удаляет.
|
||||||
requestAnimationFrame(() => {
|
const handleBeforeInput = (e: FormEvent<HTMLInputElement>) => {
|
||||||
const el = ref.current
|
const native = e.nativeEvent as InputEvent
|
||||||
if (!el) return
|
if (native.data && /\D/.test(native.data)) {
|
||||||
const pos = digitIdxToCursor(digitIdx, newDigits)
|
e.preventDefault()
|
||||||
el.setSelectionRange(pos, pos)
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
// После любого нативного редактирования (печать/удаление/paste/drag-drop)
|
||||||
// Шорткаты и навигация — мимо.
|
// нормализуем значение. Если юзер случайно стер часть префикса — он
|
||||||
if (e.metaKey || e.ctrlKey || e.altKey) return
|
// автоматически восстановится при ре-рендере (display всегда начинается с PREFIX).
|
||||||
if (['Tab', 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'Enter', 'Escape'].includes(e.key)) return
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const newDigits = rawToUserDigits(e.target.value)
|
||||||
e.preventDefault()
|
onChange(newDigits.length > 0 ? `+77${newDigits}` : '')
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
// Любой другой символ — игнор.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
// Не пускаем курсор/selection в префикс. onSelect ловит любые попытки
|
||||||
e.preventDefault()
|
// (клик, стрелки, Cmd+A) и мягко двигает на digit-зону.
|
||||||
const newDigits = extractDigits(e.clipboardData.getData('text'))
|
|
||||||
commit(newDigits, newDigits.length)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Не даём поставить курсор в «+7 » префикс — мягко двигаем на первую digit-позицию.
|
|
||||||
const guardCursor = (el: HTMLInputElement) => {
|
const guardCursor = (el: HTMLInputElement) => {
|
||||||
const start = el.selectionStart ?? 0
|
const start = el.selectionStart ?? 0
|
||||||
const end = el.selectionEnd ?? 0
|
const end = el.selectionEnd ?? 0
|
||||||
|
|
@ -117,16 +71,13 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => guardCursor(e.currentTarget)
|
||||||
const handleFocus = () => {
|
const handleFocus = () => {
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (!el) return
|
if (!el) return
|
||||||
requestAnimationFrame(() => guardCursor(el))
|
requestAnimationFrame(() => guardCursor(el))
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSelect = (e: React.SyntheticEvent<HTMLInputElement>) => {
|
|
||||||
guardCursor(e.currentTarget)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|
@ -134,13 +85,12 @@ export function PhoneInput({ value, onChange, className, disabled, ...rest }: Ph
|
||||||
inputMode="tel"
|
inputMode="tel"
|
||||||
autoComplete="tel"
|
autoComplete="tel"
|
||||||
value={display}
|
value={display}
|
||||||
onChange={() => { /* no-op: ввод управляется через onKeyDown/onPaste */ }}
|
onBeforeInput={handleBeforeInput}
|
||||||
onKeyDown={handleKeyDown}
|
onChange={handleChange}
|
||||||
onPaste={handlePaste}
|
|
||||||
onFocus={handleFocus}
|
|
||||||
onSelect={handleSelect}
|
onSelect={handleSelect}
|
||||||
|
onFocus={handleFocus}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder="+7 700 123 45 67"
|
placeholder={PREFIX}
|
||||||
className={cn(inputClass, className)}
|
className={cn(inputClass, className)}
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue