fix(регистрация/моб): свайп с кнопки прокручивает, а не отправляет форму
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker Public / Build + push Public (test + latest) (push) Waiting to run
Docker Public / Deploy :test → test.food-market.kz (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Регрессия к 3322da0: submit запускался прямо на pointerdown, поэтому прокрутка,
начатая пальцем на широкой кнопке «Начать бесплатно»/«Войти», отправляла форму.
Теперь на pointerdown только preventDefault (держим фокус в поле — blur-ошибка
не сдвигает вёрстку) и запоминаем точку касания. Отправляем на pointerup,
только если палец не сдвинулся больше порога (10px); pointercancel (жест стал
прокруткой) сбрасывает — submit не происходит. Тап отправляет с первого раза,
свайп с кнопки просто прокручивает страницу. onSubmit формы сохранён для Enter,
submittingRef защищает от двойного запроса.
Применено к форме регистрации и форме входа кабинета.
Проверено настоящими touch-жестами в Chromium (mobile 360×676, CDP
dispatchTouchEvent): тап → submit; свайп вверх 250px с кнопки → страница
прокручивается, submit НЕ срабатывает; долгое нажатие без движения → submit;
валидная форма — ровно один запрос.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
3322da06a0
commit
87a4666c25
|
|
@ -162,17 +162,31 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
|
||||||
// onSubmit формы — срабатывает на Enter с клавиатуры.
|
// onSubmit формы — срабатывает на Enter с клавиатуры.
|
||||||
const submit = (ev: React.FormEvent) => { ev.preventDefault(); runSubmit() }
|
const submit = (ev: React.FormEvent) => { ev.preventDefault(); runSubmit() }
|
||||||
|
|
||||||
// Касание кнопки «Начать бесплатно»: отправляем на pointerdown, ДО того как
|
// Касание кнопки «Начать бесплатно». На pointerdown только preventDefault —
|
||||||
// смена фокуса (blur поля) успеет перерисовать форму и сдвинуть кнопку.
|
// фокус остаётся в поле, blur-ошибка не вставляется и вёрстка не прыгает
|
||||||
// preventDefault не даёт фокусу уйти с поля (значит blur-валидация не
|
// (иначе кнопка уезжала и первое касание терялось). Отправляем на pointerup,
|
||||||
// вставит ошибку и вёрстка не прыгнет) и подавляет последующий click, так что
|
// только если палец НЕ сдвинулся больше порога и жест не стал прокруткой
|
||||||
// первое касание всегда отправляет форму — валидация всех полей + прокрутка и
|
// (pointercancel → tapRef сбрасывается). Так тап отправляет с первого раза,
|
||||||
// фокус к первой ошибке. Фикс потери первого касания на мобильном.
|
// а свайп, начатый на кнопке, просто прокручивает страницу.
|
||||||
const onSubmitBtnPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
const TAP_SLOP = 10
|
||||||
|
const tapRef = useRef<{ id: number; x: number; y: number; moved: boolean } | null>(null)
|
||||||
|
const onBtnPointerDown = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
tapRef.current = { id: e.pointerId, x: e.clientX, y: e.clientY, moved: false }
|
||||||
|
}
|
||||||
|
const onBtnPointerMove = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
const t = tapRef.current
|
||||||
|
if (!t || e.pointerId !== t.id) return
|
||||||
|
if (Math.abs(e.clientX - t.x) > TAP_SLOP || Math.abs(e.clientY - t.y) > TAP_SLOP) t.moved = true
|
||||||
|
}
|
||||||
|
const onBtnPointerUp = (e: React.PointerEvent<HTMLButtonElement>) => {
|
||||||
|
const t = tapRef.current
|
||||||
|
tapRef.current = null
|
||||||
|
if (!t || e.pointerId !== t.id || t.moved) return
|
||||||
runSubmit()
|
runSubmit()
|
||||||
}
|
}
|
||||||
|
const onBtnPointerCancel = () => { tapRef.current = null }
|
||||||
|
|
||||||
// text-base на мобильной ширине (16px) → iOS Safari не зумит страницу при
|
// text-base на мобильной ширине (16px) → iOS Safari не зумит страницу при
|
||||||
// фокусе на поле; на sm и выше — привычные 14px. user-scalable=no НЕ ставим.
|
// фокусе на поле; на sm и выше — привычные 14px. user-scalable=no НЕ ставим.
|
||||||
|
|
@ -314,7 +328,10 @@ export default function SignupForm({ defaultPlan = 'start' }: Props) {
|
||||||
{fieldErrors.agree && <p className="text-xs text-red-600 mt-1">{fieldErrors.agree}</p>}
|
{fieldErrors.agree && <p className="text-xs text-red-600 mt-1">{fieldErrors.agree}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={busy} onPointerDown={onSubmitBtnPointerDown} className="w-full px-4 py-3 bg-brand text-white font-semibold rounded-md hover:bg-[#009305] disabled:opacity-60">
|
<button type="submit" disabled={busy}
|
||||||
|
onPointerDown={onBtnPointerDown} onPointerMove={onBtnPointerMove}
|
||||||
|
onPointerUp={onBtnPointerUp} onPointerCancel={onBtnPointerCancel}
|
||||||
|
className="w-full px-4 py-3 bg-brand text-white font-semibold rounded-md hover:bg-[#009305] disabled:opacity-60">
|
||||||
{busy ? 'Регистрация…' : 'Начать бесплатно (90 дней)'}
|
{busy ? 'Регистрация…' : 'Начать бесплатно (90 дней)'}
|
||||||
</button>
|
</button>
|
||||||
<p className="text-xs text-center text-slate-500">Уже есть аккаунт? <a href={`${APP_URL}/login`} className="text-brand underline">Войти</a></p>
|
<p className="text-xs text-center text-slate-500">Уже есть аккаунт? <a href={`${APP_URL}/login`} className="text-brand underline">Войти</a></p>
|
||||||
|
|
|
||||||
|
|
@ -36,14 +36,29 @@ export function LoginPage() {
|
||||||
// onSubmit формы (Enter с клавиатуры).
|
// onSubmit формы (Enter с клавиатуры).
|
||||||
function handleSubmit(e: FormEvent) { e.preventDefault(); runSubmit() }
|
function handleSubmit(e: FormEvent) { e.preventDefault(); runSubmit() }
|
||||||
|
|
||||||
// Касание кнопки «Войти»: отправляем на pointerdown, ДО того как blur поля
|
// Касание кнопки «Войти». На pointerdown только preventDefault — фокус
|
||||||
// перерисует форму и сдвинёт кнопку (иначе на мобильном первое касание
|
// остаётся в поле, blur-ошибка не сдвигает вёрстку (иначе первое касание
|
||||||
// терялось). preventDefault удерживает фокус в поле и подавляет click.
|
// терялось). Отправляем на pointerup, только если палец не сдвинулся и жест
|
||||||
function onSubmitBtnPointerDown(e: React.PointerEvent<HTMLButtonElement>) {
|
// не стал прокруткой (pointercancel сбрасывает). Свайп с кнопки — прокрутка.
|
||||||
|
const TAP_SLOP = 10
|
||||||
|
const tapRef = useRef<{ id: number; x: number; y: number; moved: boolean } | null>(null)
|
||||||
|
function onBtnPointerDown(e: React.PointerEvent<HTMLButtonElement>) {
|
||||||
if (e.button !== 0) return
|
if (e.button !== 0) return
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
tapRef.current = { id: e.pointerId, x: e.clientX, y: e.clientY, moved: false }
|
||||||
|
}
|
||||||
|
function onBtnPointerMove(e: React.PointerEvent<HTMLButtonElement>) {
|
||||||
|
const t = tapRef.current
|
||||||
|
if (!t || e.pointerId !== t.id) return
|
||||||
|
if (Math.abs(e.clientX - t.x) > TAP_SLOP || Math.abs(e.clientY - t.y) > TAP_SLOP) t.moved = true
|
||||||
|
}
|
||||||
|
function onBtnPointerUp(e: React.PointerEvent<HTMLButtonElement>) {
|
||||||
|
const t = tapRef.current
|
||||||
|
tapRef.current = null
|
||||||
|
if (!t || e.pointerId !== t.id || t.moved) return
|
||||||
runSubmit()
|
runSubmit()
|
||||||
}
|
}
|
||||||
|
function onBtnPointerCancel() { tapRef.current = null }
|
||||||
|
|
||||||
async function runSubmit() {
|
async function runSubmit() {
|
||||||
const em = validateEmail(email)
|
const em = validateEmail(email)
|
||||||
|
|
@ -154,7 +169,10 @@ export function LoginPage() {
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onPointerDown={onSubmitBtnPointerDown}
|
onPointerDown={onBtnPointerDown}
|
||||||
|
onPointerMove={onBtnPointerMove}
|
||||||
|
onPointerUp={onBtnPointerUp}
|
||||||
|
onPointerCancel={onBtnPointerCancel}
|
||||||
className="w-full rounded-md bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] disabled:opacity-60 text-white font-medium py-2.5 text-sm transition-colors"
|
className="w-full rounded-md bg-[var(--color-brand)] hover:bg-[var(--color-brand-hover)] disabled:opacity-60 text-white font-medium py-2.5 text-sm transition-colors"
|
||||||
>
|
>
|
||||||
{loading ? 'Выполняется вход…' : 'Войти'}
|
{loading ? 'Выполняется вход…' : 'Войти'}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue