Реальные истории:Как тренер футбольной школы создал приложения за 1 неделю скоро

Формы и клавиатура

Шаг 6 UI-трека. Кнопка «+ Новая привычка» наконец делает то, что обещает: открывает экран с формой — название, время, цвет, заметка. По дороге — поля ввода и их стили, цепочка фокуса по клавише «далее», валидация без раздражения и то немногое, что нужно знать о клавиатуре (спойлер: почти ничего — хост берёт её на себя).

Поле ввода

UIInput() — однострочное поле, UITextArea() — многострочное. Текст живёт в свойстве .value (не в стиле), каждое изменение приходит в .onChange(v => …). Как и кнопка, поле не рисует ничего своего — фон, рамку, скругление и высоту задаёшь ты; без явной height поле сожмётся до высоты текста, без width или flexGrow — до ширины плейсхолдера. Рамка фокуса — системный класс $focused:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })

const name = signal("")

const nameInput = UIInput()
  .style({
    height: 52, px: 14, borderRadius: 12, fontSize: 16,
    bgColor: colors.card, border: `1px solid ${colors.line}`,
    placeholder: "Например, «Утренняя пробежка»", placeholderColor: colors.muted,
    $focused: { borderColor: colors.accent },
  })
  .onChange((v) => { name.value = v })

const screen = UIScreen(
  UIText("Новая привычка").style({ fontSize: 28, fontWeight: 800 }),
  UIText("Название").style({ color: colors.muted, fontSize: 13, mt: 16 }),
  nameInput,

  UIText("Так будет выглядеть карточка").style({ color: colors.muted, fontSize: 13, mt: 20 }),
  UIRow(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: colors.accent }),
    UIText(() => name.value.trim() || "Название привычки")
      .style({ fontSize: 16, fontWeight: 600, flexGrow: 1, opacity: () => (name.value.trim() ? 1 : 0.4) }),
    UIText("21:00").style({ color: colors.muted, fontSize: 13 }),
  ).style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, alignItems: "center" }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 6 })

screen.open()

Нажми на поле и начни печатать — карточка-превью обновляется с каждой буквой. Заметь: поле не «контролируемое», как в React, — оно само хранит свой текст, а onChange лишь сообщает о нём. Записать значение из кода — nameInput.value = "…", и это работает ещё до открытия экрана: так заполняют форму редактирования.

  • type — подсказка клавиатуре, а не валидатор: "email", "phone", "number", "decimal", "url", "search", "password". "number" покажет цифровую клавиатуру, но не запретит вставить буквы — проверяй в onChange.
  • type: "date" и "time" — не клавиатуры, а нативные пикеры: значение всегда каноническое ("2026-08-19", "21:00"), а показывается локализованным.
  • maxLength, autocapitalize, autocorrect — тоже стили. placeholderColor — цвет плейсхолдера, color — введённого текста.
  • UITextArea без явной height растёт вместе с текстом; ограничивай minHeight / maxHeight — дальше текст прокручивается внутри.

Клавиатура и цепочка полей

Клавиатура — почти целиком забота хоста: он сдвигает раскладку, чтобы поле было видно, докручивает до него ближайший скролл и убирает клавиатуру по универсальным правилам. Тап по кнопке или другому полю не прячет клавиатуру, прокрутка — тоже, а тап по «мёртвой» области (фон, текст) — прячет. Приложение обычно не пишет ни строчки кода про клавиатуру — кроме одной вещи: клавиши «далее».

enterKey: "next" подписывает клавишу возврата, а .onSubmit(() => next.focus()) переводит фокус — так пользователь заполняет форму, не отрывая рук от клавиатуры. У последнего поля enterKey: "done" и onSubmit отправляет форму:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })

const box: Style<UIInput> = {
  px: 14, borderRadius: 12, fontSize: 16, bgColor: colors.card, border: `1px solid ${colors.line}`,
  placeholderColor: colors.muted, $focused: { borderColor: colors.accent },
}
const Field = (label: string, input: UIInput | UITextArea) =>
  UIColumn(UIText(label).style({ color: colors.muted, fontSize: 13 }), input).style({ gap: 6 })

const time = signal("")
const nameInput = UIInput().style({ ...box, height: 52, placeholder: "Название", enterKey: "next" })
const timeInput = UIInput().style({ ...box, height: 52, type: "time", placeholder: "Выбери время" })
const noteInput = UITextArea().style({ ...box, py: 14, minHeight: 52, maxHeight: 120, placeholder: "Заметка (необязательно)" })

nameInput.onSubmit(() => timeInput.focus())        // «далее» → следующее поле
timeInput.onChange((v) => { time.value = v })       // "HH:MM" — всегда каноническое

const screen = UIScreen(
  UIText("Новая привычка").style({ fontSize: 28, fontWeight: 800, mb: 8 }),
  Field("Название", nameInput),
  Field("Время", timeInput),
  Field("Заметка", noteInput),
  UIText(() => (time.value ? `Напомним в ${time.value}` : "Время не выбрано"))
    .style({ color: colors.muted, fontSize: 15, mt: 8 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 14 })

screen.open()
  • Пока клавиатура открыта, раскладка сжимается до области над ней (keyboardShrink: true по умолчанию у поля в фокусе). Поэтому длинная форма — это UIScrollable-тело, а не колонка: хост докрутит скролл до поля, а кнопка «Сохранить» останется досягаемой. keyboardShrink: false — режим наложения для чатов, где отступ считают сами через app.keyboardHeight.
  • В UITextArea клавиша возврата — перенос строки; onSubmit у неё нет, поэтому она стоит последней или выпадает из цепочки.
  • enterKey принимает "done" | "go" | "next" | "search" | "send". После "next" клавиатура остаётся — фокус переводишь ты; после остальных прячется сама.
  • input.focus() / input.blur() — программно (например, автофокус в onOpen экрана поиска); до монтирования focus() — no-op.
  • Заметка растёт вместе с текстом, потому что у неё нет height — только minHeight и maxHeight; py: 14 вместо фиксированной высоты держит текст на месте.

Валидация

Хорошая форма «поощряет рано, ругает поздно»: пока пользователь печатает — молчит (а если ошибка уже показана, печать её убирает); при уходе с поля судит только непустое значение; на «Сохранить» проверяет всё и ставит фокус в первое проблемное поле — хост сам докрутит к нему. Ошибка — обычный сигнал, строка под полем зарезервирована minHeight, чтобы форма не прыгала:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })

const Field = (label: string, o: { placeholder: string; check: (v: string) => string | null }) => {
  const error = signal<string | null>(null)
  const input = UIInput()
    .style({
      height: 52, px: 14, borderRadius: 12, fontSize: 16, bgColor: colors.card, placeholder: o.placeholder,
      placeholderColor: colors.muted, $focused: { borderColor: colors.accent },
      border: () => `1px solid ${error.value ? colors.danger : colors.line}`,
    })
    .onChange(() => { error.value = null })                            // печатаешь — значит, исправляешь
    .onBlur(() => { if (input.value.trim()) error.value = o.check(input.value.trim()) })   // ушёл — судим непустое
  const validate = () => { error.value = o.check(input.value.trim()); return error.value === null }
  const node = UIColumn(
    UIText(label).style({ color: colors.muted, fontSize: 13 }),
    input,
    UIText(() => error.value ?? "").style({ color: colors.danger, fontSize: 13, minHeight: 18 }),   // строка зарезервирована
  ).style({ gap: 6 })
  return { node, input, validate }
}

const name = Field("Название", {
  placeholder: "Например, «Растяжка»",
  check: (v) => (v.length === 0 ? "Придумай название" : v.length < 3 ? "Слишком коротко — хотя бы 3 символа" : null),
})
const goal = Field("Цель в неделю", {
  placeholder: "От 1 до 7",
  check: (v) => (/^[1-7]$/.test(v) ? null : "Число от 1 до 7"),
})
goal.input.style({ type: "number" })

const saved = signal("")
const submit = () => {
  const fields = [name, goal]
  const firstBad = fields.filter((f) => !f.validate())[0]         // проверить всё, показать все ошибки
  if (firstBad) { firstBad.input.focus(); return }                // и сфокусировать первую
  saved.value = `Сохранено: «${name.input.value.trim()}», ${goal.input.value} р/нед`
}

const screen = UIScreen(
  UIText("Новая привычка").style({ fontSize: 28, fontWeight: 800, mb: 8 }),
  name.node,
  goal.node,
  UIButton(UIText("Сохранить").style({ fontSize: 16, fontWeight: 700 }))
    .style({ bgColor: colors.accent, borderRadius: 16, p: 16, $pressed: { opacity: 0.8 } })
    .onClick(submit),
  UIText(() => saved.value).style({ color: "#34c759", fontSize: 15 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 8 })

screen.open()

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

Обрати внимание на Field — она возвращает не элемент, а объект { node, input, validate }: узел для вёрстки, поле для фокуса и функцию проверки для сабмита. Так устроен набор полей в настоящем проекте: маленький контроллер формы принимает массив таких объектов и сам собирает из него и цепочку enterKey: "next", и «проверить всё, сфокусировать первое неверное».

Экран формы в приложении

Собираем: список привычек, кнопка открывает экран «Новая привычка» поверх (Router.push), форма — название, время и цвет чипами, «Сохранить» валидирует, добавляет привычку в сигнал и возвращает назад:

TypeScript
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })
const type = { body: { fontSize: 16, fontWeight: 600 } as Style<UIText>, small: { fontSize: 13, color: colors.muted } as Style<UIText> }

type Habit = { id: number; title: string; time: string; color: string }
const habits = signal<Habit[]>([
  { id: 1, title: "Утренняя пробежка", time: "07:30", color: "#34c759" },
  { id: 2, title: "Прочитать 20 страниц", time: "13:00", color: "#ff9f0a" },
])
const PALETTE = ["#FF4032", "#34c759", "#ff9f0a", "#5e9eff", "#af6bff", "#2fd6c8"]

const NewHabitScreen = () => {
  const error = signal<string | null>(null)
  const color = signal(PALETTE[0])
  const box: Style<UIInput> = { height: 52, px: 14, borderRadius: 12, fontSize: 16, bgColor: colors.card,
    placeholderColor: colors.muted, $focused: { borderColor: colors.accent } }
  const nameInput = UIInput()
    .style({ ...box, placeholder: "Например, «Растяжка»", enterKey: "next", border: () => `1px solid ${error.value ? colors.danger : colors.line}` })
    .onChange(() => { error.value = null })
    .onSubmit(() => timeInput.focus())
  const timeInput = UIInput().style({ ...box, type: "time", placeholder: "Время", border: `1px solid ${colors.line}` })
  timeInput.value = "21:00"

  const save = () => {
    const title = nameInput.value.trim()
    if (title.length < 3) { error.value = "Название — хотя бы 3 символа"; nameInput.focus(); return }
    habits.value = [...habits.value, { id: Date.now(), title, time: timeInput.value || "21:00", color: color.value }]
    Router.pop()
  }
  const ColorChip = (c: string) =>
    UIButton()
      .style({ width: 32, height: 32, borderRadius: 16, bgColor: c, opacity: 0.45, $active: { opacity: 1, border: "3px solid #ffffff" } })
      .class({ active: () => color.value === c })
      .onClick(() => { color.value = c })

  return UIScreen(
    UIButton(UIText("← Отмена").style({ ...type.small, fontSize: 15 })).style({ alignSelf: "flex-start", py: 6 }).onClick(() => Router.pop()),
    UIText("Новая привычка").style({ fontSize: 28, fontWeight: 800, mt: 8, mb: 8 }),
    UIText("Название").style(type.small), nameInput,
    UIText(() => error.value ?? "").style({ ...type.small, color: colors.danger, minHeight: 18 }),
    UIText("Время").style(type.small), timeInput,
    UIText("Цвет").style({ ...type.small, mt: 12 }),
    UIRow(PALETTE.map(ColorChip)).style({ gap: 10 }),
    UISpacer(),
    UIButton(UIText("Сохранить").style({ ...type.body, fontWeight: 700 }))
      .style({ bgColor: () => color.value, borderRadius: 16, p: 16, $pressed: { opacity: 0.8 } })
      .onClick(save),
  ).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)", gap: 6 })
    .onBackPressed(() => Router.pop())
}

const HabitRow = (h: Habit) =>
  UIRow(
    UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color }),
    UIText(h.title).style({ ...type.body, flexGrow: 1 }),
    UIText(h.time).style(type.small),
  ).style({ bgColor: colors.card, borderRadius: 16, p: 14, gap: 12, alignItems: "center" })

const home = UIScreen(
  UIText("Сегодня").style({ fontSize: 32, fontWeight: 800 }),
  UIScrollable(() => habits.value.map(HabitRow)).style({ flexGrow: 1, gap: 10, mt: 12, showScrollbar: false }),
  UIButton(UIText("+ Новая привычка").style({ ...type.body, fontWeight: 700 }))
    .style({ bgColor: colors.accent, borderRadius: 16, p: 16, mt: 12, $pressed: { opacity: 0.8 } })
    .onClick(() => Router.push(NewHabitScreen())),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", pb: "max(safe-bottom, 20px)" })

Router.init(home)

Экран формы — фабрика без аргументов: каждый раз она строит чистую форму со своими сигналами, поэтому после сохранения ничего сбрасывать не нужно — следующий push создаст новую. Кнопка «Сохранить» красится в выбранный цвет привязкой bgColor: () => color.value — маленький, но приятный отклик на выбор. Заметка из блока про клавиатуру сюда не вошла только ради длины листинга.

Что ты узнал

  • UIInput / UITextArea: текст в .value, изменения в onChange; поле не рисует и не сжимает ничего само — height, фон и рамка твои; фокус — $focused.
  • type — подсказка клавиатуре, а "date" / "time" — нативные пикеры с каноническим значением.
  • Цепочка полей: enterKey: "next" + onSubmit(() => next.focus()); последнее поле — "done" и отправка. Остальное про клавиатуру делает хост; длинная форма — в UIScrollable.
  • Валидация: молчать при вводе, судить непустое при уходе, на сабмите проверить всё и сфокусировать первое неверное; строку ошибки резервировать minHeight.
  • Экран формы — фабрика со своим состоянием; сохранение пишет в сигнал приложения и делает Router.pop().

Справочник: Кнопки и поля ввода · Классы стилей · Приложение и клавиатура · Экраны и роутер.

Дальше: оверлеи — диалог подтверждения удаления, шторка выбора и меню по кнопке «⋯»: UIModal, UIBottomSheet, UIPopover.