Форма без библиотек

ввод≈ 16 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

В прошлой статье поля «Подхода» научились принимать вес и повторы: нужная клавиатура, запятая вместо точки, словарь fieldKinds. Теперь из них собирается форма подхода — та, что заполняют между подходами, часто одной рукой. В части II форму давал браузер: тег <form>, отправка по Enter, переход по Tab, required с подсказкой у поля. В React Native нет ни одного из этих пунктов. Остаются состояние — как в части IV, ref с методом focus() и пара свойств поля, отвечающих за клавишу ввода на экранной клавиатуре. Из них и соберём форму: фокус идёт по полям клавишей «Далее», ошибка встаёт под полем, подход записывается. Этаж — только React Native: своего для форм у Expo нет, а библиотека форм на двух полях спрятала бы ровно то, что здесь стоит увидеть.

Разделы темы:·

Сначала опись. Всё, на чём держалась форма в статьях части II «Отправка формы и событие submit» и «Встроенная валидация и своя», на телефоне либо исчезает, либо переходит к вашему коду:

В браузереВ React NativeКто теперь делает
<form> и событие submitнет, вокруг полей обычный Viewсвоя функция записи
Enter в поле отправляет формуклавиша ввода зовёт onSubmitEditing одного поляобработчик этого поля
Tab — к следующему полюTab на экранной клавиатуре нетfocus() следующего поля через ref
required, pattern, validityнетсвоя функция проверки
подсказка и фокус на первом неверном поленетText под полем и свой focus()
<form action={…}> и useFormStatus из части IVнет: оба держатся на элементе формы из react-domта же функция записи

Начнём со строки про клавишу ввода. Её подпись на экранной клавиатуре задаёт свойство returnKeyType: "next" — «Далее», "done" — «Готово», "send" — «Отправить». Эти три вместе с "go" и "search" есть на обеих платформах, а у iOS и Android есть ещё и свои. Рядом — более новое enterKeyHint со значениями одноимённого атрибута HTML, и если заданы оба, главнее оно: та же пара, что keyboardType и inputMode в прошлой статье. Нажатие клавиши приходит в onSubmitEditing, текст поля — в e.nativeEvent.text, как у onChange. autoFocus ставит курсор в поле, как только оно появилось: форму открыли — клавиатура уже поднята под вес.

Клавиатуры в песочнице нет, поэтому клавишу жмёт помощник pressReturn(testID) из подложки: он шлёт полю Enter, а react-native-web превращает его в onSubmitEditing. Там же — fieldKinds, parseAmount и inspectField из прошлой статьи; последний теперь читает ещё и подпись клавиши. Прогноз: у веса стоит returnKeyType="next". Что напечатается и попадёт ли фокус в повторы?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
Последняя строка — вопрос примера самому себе после обработчика: отпустило ли поле фокус (истина — отпустило). Само событие blur могло прийти в любой момент между фокусом и вопросом: в медленном окружении оно иногда опережало таймер, и порядок строк плясал. Поэтому пример держит событие во флаге, а не печатает из обработчика: цепочка видна, порядок стабилен.

Строки «фокус: повторы» нет: returnKeyType — только надпись на клавише. В предпросмотре она уехала в атрибут enterkeyhint, по которому браузер телефона подписал бы свою клавишу, и больше ничего не сделала. Сам React Native фокус тоже не передаст. В исходнике Android-поля (ReactTextInputManager.kt) системе прямо запрещают двигать фокус к соседнему полю — «since it must only be controlled from JS», им управляют только из JS. Зато поле, на котором нажали клавишу, фокус отпустило само: последняя строка примера спрашивает про это после обработчика и получает истину.

Это поведение по умолчанию, и у него есть имя — submitBehavior. Значений три:

submitBehaviorЧто делает клавиша вводаУмолчание для
"blurAndSubmit"зовёт onSubmitEditing и снимает с поля фокус — клавиатура уходит вместе с нимоднострочного поля
"submit"только зовёт onSubmitEditing; фокус и клавиатура остаются—
"newline"вставляет перенос строки, onSubmitEditing не зовётмногострочного (multiline)

Правило записано в TextInput.js react-native 0.86: однострочному полю без явного submitBehavior достаётся "blurAndSubmit", а заданный ему "newline" превращается туда же — переносить в одной строке нечего. Старое свойство blurOnSubmit в типах помечено устаревшим, и submitBehavior его перекрывает.

Раз фокус переводит только JS, нужен доступ к следующему полю — ref, заведённый как в части IV (статья «Ссылки на узлы: useRef и ref как пропс»). Меняется то, что в нём лежит. Не HTMLInputElement, а нативный элемент поля — тот же, что в прошлой статье нашёлся в e.target, с его focus(), blur() и measure(). TextInput добавляет к нему свои методы, и в типах react-native их три: clear(), isFocused() и setSelection(). Четвёртый, getNativeRef(), в рантайме react-native есть, но в его d.ts не объявлен, и компилятор на него скажет Свойство "getNativeRef" не существует в типе "TextInput". Ни value, ни form, ни validity у него нет: значение живёт в состоянии, а не в поле.

Предпросмотр здесь снова льстит, как с e.target.value в прошлой статье. Под ref в песочнице лежит <input> от react-native-web: value у него есть, зато из трёх методов поля — только clear() и isFocused(): setSelection() нет, как нет и getNativeRef(). Компилятор держится телефона: на ref.current?.value он скажет Свойство "value" не существует в типе "TextInput".

Тип пишется так же, как для View в первой статье части: React.ComponentRef<typeof TextInput>. Короткое useRef<TextInput> компилятор не пропустит: "TextInput" относится к значению, но здесь используется как тип. Прогноз для цепочки «вес → Далее → повторы → Готово»: какие строки придут и в каком порядке?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера
разбор вывода
«Ушёл: вес» и «фокус: повторы» пришли прямо изнутри focus(): события фокуса синхронные, и когда обработчик «Далее» вернулся, курсор уже стоял в повторах. «Готово» увидело свежие значения, потому что каждый ввод отрисовался сразу — это правило из прошлой статьи. А «ушёл: повторы» пришло последним, после конца такта: у последнего поля submitBehavior не задан, и по умолчанию оно отпускает фокус после обработчика.

У веса стоит submitBehavior="submit", и в предпросмотре он не изменил ничего: react-native-web этого свойства не читает — в его TextInput есть только старое blurOnSubmit, а неизвестные свойства он молча отбрасывает. На телефоне разница видна глазами. С умолчанием "blurAndSubmit" поле по клавише завершает ввод и прячет клавиатуру: на Android это clearFocusAndMaybeRefocus(), который кончается вызовом hideSoftKeyboard(), на iOS — endEditing. Следующей строкой ваш focus() поднимает клавиатуру снова, и на каждом «Далее» она дёргается вниз и вверх. С "submit" фокус с поля не снимается, клавиатура остаётся на месте, а фокус просто переезжает к соседу.

Отсюда правило цепочки: у всех полей, кроме последнего, — returnKeyType="next", submitBehavior="submit" и focus() следующего в onSubmitEditing. У последнего — "done" и умолчание: форма заполнена, клавиатуру пора убирать.

Спросить поле, в фокусе ли оно, — ref.current?.isFocused(); это работает и в предпросмотре, и на телефоне. В типах есть и способ спросить сразу всё приложение: TextInput.State.currentlyFocusedInput(), и компилятор его пропускает. Прогноз: что будет при вызове в предпросмотре?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Из двух способов спросить, какое поле в фокусе, react-native-web кладёт в TextInput.State только старый — currentlyFocusedField. Ноль ошибок компиляции и TypeError при запуске — тот же случай «типы шире рантайма», что в статье «Что в этой песочнице настоящее». На телефоне метод есть, но и там за ним не нужно ходить: какое поле в фокусе, форма знает сама, по своим onFocus и onBlur.

Проверки браузера — required, pattern, validity — остались в части II. Здесь проверка — обычная функция: берёт значения формы и отдаёт объект ошибок, у верного поля ключа в нём нет. Числа она разбирает через parseAmount из прошлой статьи, поэтому запятая в весе ошибкой не считается. Прогноз для четырёх черновиков подхода:

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

У верного черновика объект пуст — «можно записывать» проверяется как Object.keys(errors).length === 0. «5,5» parseAmount честно разобрал в 5.5, и остановила его уже проверка на целое: дробных повторов не бывает. У последнего черновика неверно всё, и ошибок две — показывать надо обе, каждую под своим полем.

Теперь — когда показывать. Если выводить ошибки на каждой отрисовке, человек увидит красное под весом раньше, чем нажмёт первую цифру. В части II эту задачу решал псевдокласс :user-invalid: он ждёт, пока с полем поработают. CSS здесь нет, поэтому «поработал» запоминаем сами — в touched, когда поле теряет фокус; в цепочке это как раз нажатие «Далее». Сама ошибка — обычный Text под полем, а рамка краснеет вторым стилем в массиве, как в разделе «Массив в пропсе style: кто кого перекрывает» статьи про StyleSheet.create.

Поле с ошибкой под ним собирает компонент FormField. Ref в React 19 — обычный проп, это тоже из статьи части IV про ref, поэтому FormField просто пропускает его в TextInput вместе с остальными свойствами. validateSet уже в подложке. Прогноз: что будет написано под полями после каждой отрисовки?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Пока человек печатал в весе «8,2,5», поле молчало: оно ещё не тронуто. Красное появилось по «Далее» — фокус ушёл в повторы, вес получил onBlur. А исчезло оно сразу, как только вес исправили, не дожидаясь нового ухода с поля: touched у веса уже true, а ошибки пересчитываются на каждой отрисовке. Под повторами пусто, хотя повторов нет: туда только вошли и ещё не выходили.

Отправки нет — есть функция, которую зовёт клавиша «Готово» на последнем поле, а в полной форме ещё и кнопка. После записи поле повторов чистится, а клавиатура прячется вызовом Keyboard.dismiss(): начинается отдых, и половина экрана под клавиатурой больше не нужна. Keyboard — модуль React Native, и dismiss() у него делает одно: снимает фокус с поля, которое сейчас в фокусе. В react-native это одна строка, blurTextInput(currentlyFocusedInput()), и в react-native-web — та же, только со старым currentlyFocusedField(). Возьмём одно поле и правило показа из прошлого раздела. Прогноз: сколько отрисовок и что будет под полем после записи?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Подход записался, поле очистилось — и тут же покраснело. Причина в строке, где про ошибки нет ни слова: Keyboard.dismiss() снял фокус с поля, а снятие фокуса — это onBlur, и он пометил поле тронутым. Все три сеттера сработали внутри одного обработчика и ушли в одну очередь, как в статье части IV «Функция-обновитель и очередь обновлений»; setTouched(true) из onBlur встал в неё последним и перебил setTouched(false).

Убрать Keyboard.dismiss() не поможет. Последнее поле отпускает фокус по «Готово» и само — это умолчание "blurAndSubmit" из первого раздела. Изменится только время: в предпросмотре этот onBlur придёт тактом позже, отдельной отрисовкой, и красное появится на ней. Бороться с порядком событий бесполезно: на телефоне onBlur приходит из нативного поля событием, и когда именно — решает не ваш код. Менять надо правило показа.

Новое правило такое: пустое поле при уходе с него не ругаем — ругаем только после попытки записать. Непустое неверное поле краснеет по-прежнему, после ухода. Человек, пролиставший поле «Далее», ещё ничего не ввёл и ни в чём не ошибся, а после записи поле пустое потому, что его очистили мы. Попытку записать отмечает отдельный флаг submitted — он и показывает все ошибки разом.

Keyboard.dismiss() не «прячет клавиатуру» сам по себе: клавиатура на телефоне живёт, пока в фокусе поле. Поэтому любой вызов dismiss — это чей-то onBlur.

Соберём всё. submit зовут двое: кнопка «Записать» и «Готово» на последнем поле. При ошибке он отмечает попытку и ставит курсор в первое неверное поле — так делал браузер в части II, когда форму с ошибкой пытались отправить. Какое поле первое, решает список order — порядок полей на экране. Из него же выводится цепочка: у каждого поля, кроме последнего, «Далее» ведёт к следующему, у последнего «Готово» зовёт submit.

Вес пришёл из прошлого подхода. FormField и validateSet — в подложке. Прогноз: сначала «Записать» при пустых повторах, потом ввод повторов и «Готово». Что напечатается и где окажется фокус?

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Первое нажатие ничего не записало: ошибка показалась под повторами, и курсор встал туда же — искать, что не так, не нужно. Второе прошло через «Готово»: подход записан числами, вес остался, повторы очищены, и под ними пусто, хотя Keyboard.dismiss() снова снял фокус и onBlur снова пометил поле. Пометка есть, а красного нет: поле пустое, и попытки записать ещё не было.

Третье поле — например, отдых в секундах — теперь добавляется в Field, order и validateSet, и всё. returnKeyType, submitBehavior и то, куда ведёт клавиша, пересчитаются сами: «Далее» у повторов поведёт в отдых, «Готово» переедет на отдых.

Одну ловушку формы предпросмотр не покажет вовсе. На экране тренировки форма стоит под списком подходов, а экран прокручивается — это ScrollView. Пока открыта клавиатура, первое касание мимо поля ScrollView по умолчанию забирает себе: клавиатура прячется, а кнопка «Записать» нажатия не получает, и человек жмёт второй раз. Так и написано в описании свойства keyboardShouldPersistTaps: при "never", которое стоит по умолчанию, дочерние элементы такого касания не получат. Лечится одним свойством:

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

С "handled" касание, которое кто-то внутри обработал, — наша кнопка — до кнопки и доходит, а клавиатуру прячет Keyboard.dismiss() в submit. Касание по пустому месту клавиатуру по-прежнему убирает. У FlatList то же свойство — он построен на ScrollView. В предпросмотре разницы не будет: в ScrollView react-native-web перехват первого касания закомментирован, а fireEvent.press — щелчок, до которого прокрутке дела нет. Что делать, когда клавиатура закрывает саму форму, — в последней статье главы.

FormField и validateSet переезжают в подложку следующих статей главы, а форма там понадобится уже готовой. Следующая разбирает, что на самом деле происходит внутри кнопки «Записать» между касанием и onPress.

  • Формы в React Native нет: ни <form>, ни submit, ни Tab, ни required. Её собирают из состояния, ref с focus() и свойств клавиши ввода.
  • returnKeyType (или более новое enterKeyHint) — только подпись клавиши. Нажатие приходит в onSubmitEditing, а в следующее поле фокус переводит ваш код: nextRef.current?.focus().
  • Однострочное поле по умолчанию отпускает фокус по клавише ввода ("blurAndSubmit"). В цепочке ставьте submitBehavior="submit", иначе клавиатура на телефоне дёргается на каждом «Далее»; предпросмотр этого свойства не читает.
  • Под ref у TextInput — нативный элемент с focus(), blur(), clear() и isFocused(), тип — React.ComponentRef<typeof TextInput>. Значения в нём нет: оно в состоянии, поэтому и чистят состояние, а не поле.
  • Ошибка — Text под полем. Показывать её после ухода с поля или попытки записать, но пустое поле при уходе не ругать: «Готово» и Keyboard.dismiss() снимают фокус и после записи.
  • Одна функция submit на кнопку и «Готово»; при ошибке — фокус в первое неверное поле по порядку на экране. Форме в ScrollView нужен keyboardShouldPersistTaps="handled", иначе первое касание кнопки на телефоне только спрячет клавиатуру.

1/4У поля веса стоит returnKeyType="next", других свойств клавиши нет. Что будет, когда человек нажмёт «Далее»?

2/4Зачем полям цепочки, кроме последнего, submitBehavior="submit"?

3/4После записи поле повторов очищают и зовут Keyboard.dismiss(). Почему под пустым полем сразу появляется ошибка?

4/4Форма стоит внутри ScrollView. На телефоне первое нажатие на «Записать» при открытой клавиатуре ничего не записывает. Почему?

«Далее», которое никуда не ведёт

Подписи клавиш уже стоят, а цепочки нет: «Далее» на весе фокус никуда не переводит, «Готово» на повторах ничего не делает. Доведите форму: «Далее» ведёт в повторы, «Готово» печатает записать: вес × повторы. Клавиатура на телефоне между полями прятаться не должна.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

Нужны ref на повторы и два обработчика клавиши; submitBehavior="submit" на весе — ради телефона: без него клавиатура закроется и откроется заново.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: фокус: вес, фокус: повторы, записать: 80 × 8. «Готово» видит «8», хотя ввод и нажатие шли в одном такте: каждый fireEvent.changeText отрисовывается сразу, и к нажатию у поля уже свежий обработчик. У последнего поля submitBehavior не нужен: умолчание снимет фокус, и клавиатура уйдёт — форма заполнена.

Крестик, который стирает не то

Рядом с весом — крестик «очистить». Он зовёт clear() — метод, который у поля правда есть, — и поле на экране пустеет. Но «Записать» после этого пишет старый вес. Почините крестик: после нажатия пусто должно быть и в поле, и в состоянии, а курсор должен стоять в весе, чтобы сразу набрать новый.

rn
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
экран примера

clear() — команда нативному полю, а не вашему состоянию: в исходнике react-native это setTextAndSelection с пустой строкой, в react-native-web — присваивание value узлу. Текст на экране и значение в состоянии разъехались, и подход запишется с весом, которого человек уже не видит. Чистят состояние — поле опустеет само, потому что его value берётся оттуда.

tsxлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: фокус в поле, в поле: "", в состоянии: "". clear() остаётся для неуправляемых полей — тех, что живут на defaultValue и в состояние ничего не пишут.

Подтягивания: вес не обязателен

В справочнике «Подхода» есть упражнения с собственным весом: подтягивания, отжимания, планка. Там пустой вес — не ошибка, а «без отягощения». Научите validateSet понимать флаг bodyweight: пустой вес при нём проходит, а вес, который не разбирается в число, остаётся ошибкой. Для обычных упражнений ничего не меняется.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Пустоту проверяем до разбора: parseAmount вернёт null и для пустой строки, и для «1,,5», а различать нужно именно их.

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Вывод: {}, {}, ошибка веса для «1,,5» и ошибка веса у жима без веса. Записывать пустой вес подтягиваний стоит числом 0, а не пустой строкой: тоннаж, графики и сравнение с прошлой тренировкой считают числа.