Формы и клавиатура
Шаг 6 UI-трека. Кнопка «+ Новая привычка» наконец делает то, что обещает: открывает экран с формой — название, время, цвет, заметка. По дороге — поля ввода и их стили, цепочка фокуса по клавише «далее», валидация без раздражения и то немногое, что нужно знать о клавиатуре (спойлер: почти ничего — хост берёт её на себя).
Поле ввода
UIInput() — однострочное поле, UITextArea() — многострочное. Текст живёт в свойстве .value (не в стиле), каждое изменение приходит в .onChange(v => …). Как и кнопка, поле не рисует ничего своего — фон, рамку, скругление и высоту задаёшь ты; без явной height поле сожмётся до высоты текста, без width или flexGrow — до ширины плейсхолдера. Рамка фокуса — системный класс $focused:
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 отправляет форму:
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, чтобы форма не прыгала:
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), форма — название, время и цвет чипами, «Сохранить» валидирует, добавляет привычку в сигнал и возвращает назад:
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.