Оверлеи
Шаг 7 UI-трека. Не всё заслуживает отдельного экрана: подтверждение удаления, выбор «повторять по будням», меню из трёх пунктов у строки — это слои поверх текущей страницы. В LeCodes у них один корень — UIWidget — и три готовые формы: UIModal для диалогов, UIBottomSheet для шторок снизу и UIPopover для меню у кнопки. Плюс toast — самая дешёвая обратная связь.
Слой над страницей
UIWidget — плавающий элемент над текущей страницей, позиционируется в координатах экрана (top / left / right / bottom, безопасные зоны работают). Он невидим, пока не вызван show(), и создаётся один раз на уровне модуля — видимость императивна, пересоздавать виджет на каждый показ не нужно. Стиль overlayColor добавляет под ним подложку, которая перехватывает тапы, — так виджет становится модальным.
Для стандартного диалога собирать это руками не нужно: UIModal — тот же виджет, но уже с подложкой, анимацией появления и закрытием по тапу мимо и по кнопке «назад». Один диалог на всё приложение — а какую привычку удаляем, помнит сигнал:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })
type Habit = { id: number; title: string; color: string }
const habits = signal<Habit[]>([
{ id: 1, title: "Утренняя пробежка", color: "#34c759" },
{ id: 2, title: "Прочитать 20 страниц", color: "#ff9f0a" },
{ id: 3, title: "Растяжка", color: "#af6bff" },
])
const pending = signal<Habit | null>(null) // что удаляем
const Btn = (label: string, bg: string, onTap: () => void) =>
UIButton(UIText(label).style({ fontSize: 15, fontWeight: 600 }))
.style({ flex: 1, height: 44, borderRadius: 12, bgColor: bg, $pressed: { opacity: 0.7 } })
.onClick(onTap)
const confirm = UIModal(
UIText("Удалить привычку?").style({ fontSize: 18, fontWeight: 700 }),
UIText(() => `«${pending.value?.title ?? ""}» и вся её история исчезнут.`).style({ color: colors.muted, fontSize: 14 }),
UIRow(
Btn("Отмена", colors.line, () => confirm.hide()),
Btn("Удалить", colors.danger, () => {
habits.value = habits.value.filter((h) => h !== pending.value)
confirm.hide()
}),
).style({ gap: 10, mt: 8 }),
).style({ left: 24, right: 24, top: "36%", p: 20, gap: 8, borderRadius: 20, bgColor: colors.card })
const HabitRow = (h: Habit) =>
UIRow(
UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color }),
UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
UIButton(UIText("Удалить").style({ color: colors.danger, fontSize: 13, fontWeight: 600 }))
.style({ height: 32, px: 12, borderRadius: 16, bgColor: colors.bg, $pressed: { opacity: 0.6 } })
.onClick(() => { pending.value = h; confirm.show() }),
).style({ bgColor: colors.card, borderRadius: 16, p: 14, pr: 10, gap: 12, alignItems: "center" })
const screen = UIScreen(
UIText("Привычки").style({ fontSize: 32, fontWeight: 800 }),
UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
UIText(() => (habits.value.length === 0 ? "Всё удалено — чистый лист" : "")).style({ color: colors.muted, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
screen.open()Нажми «Удалить» у любой строки: подложка затемняет экран, диалог появляется с плавным появлением, тап мимо или кнопка «назад» закрывают его. Ничего из этого писать не пришлось.
show()открывает виджет глобальным слоем над всеми экранами: он переживает навигацию, пока ты не вызовешьhide(). Чтобы слой принадлежал странице (HUD над сценой, подсказка на конкретном экране) —widget.attachTo(owner)доshow().- Текст диалога — привязка к сигналу
pending: один экземпляр диалога, содержимое меняется перед показом. modal.dismissible(false)выключает закрытие по тапу мимо и по «назад» — для обязательного выбора.modal.onOpen/modal.onClose— хуки,modal.isOpen— состояние.- Своя анимация —
modal.transition({ transform: "translateY(480px)", duration: 250 }): показ идёт из этой позы, скрытие — в неё. - У «голого»
UIWidgetподложка —overlayColor, тап по ней —onOverlayTap, а исчезновение с анимацией —animateTo({ opacity: 0, commit: false })+hide()по таймеру.UIModalделает всё это за тебя.
Шторка снизу
Выбор из нескольких вариантов на телефоне — это шторка от нижнего края. UIBottomSheet без настроек — как раз она: высота по содержимому, подложка, свайп вниз закрывает (на нативных хостах — с настоящей физикой пальца; в веб-симуляторе шторка статична). Всё модальное — подложка, «назад», dismissible — как у UIModal:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032" })
theme({ color: "#ffffff" })
const OPTIONS = ["Каждый день", "По будням", "По выходным", "Три раза в неделю"]
const repeat = signal(OPTIONS[0])
const Option = (label: string) =>
UIButton(
UIText(label).style({ fontSize: 16, flexGrow: 1, color: () => (repeat.value === label ? colors.accent : "#ffffff") }),
UIText("✓").style({ color: colors.accent, fontSize: 16, opacity: () => (repeat.value === label ? 1 : 0) }),
)
.style({ height: 52, px: 16, borderRadius: 12, $pressed: { bgColor: colors.line } })
.onClick(() => { repeat.value = label; sheet.hide() })
const sheet = UIBottomSheet(
UIColumn().style({ width: 44, height: 5, borderRadius: 3, bgColor: colors.line, alignSelf: "center", mb: 10 }),
UIText("Повторять").style({ fontSize: 16, fontWeight: 700, textAlign: "center", mb: 6 }),
OPTIONS.map(Option),
).style({ bgColor: colors.card, borderRadius: 20, pt: 12, px: 12, pb: "max(safe-bottom, 20px)" })
const screen = UIScreen(
UIText("Растяжка").style({ fontSize: 32, fontWeight: 800 }),
UIText("Настройки привычки").style({ color: colors.muted, fontSize: 15 }),
UIButton(
UIText("Повторять").style({ fontSize: 16, flexGrow: 1 }),
UIText(() => repeat.value).style({ color: colors.muted, fontSize: 15 }),
UIText("›").style({ color: colors.muted, fontSize: 22, ml: 8 }),
)
.style({ bgColor: colors.card, borderRadius: 16, p: 14, mt: 16, $pressed: { opacity: 0.6 } })
.onClick(() => sheet.show()),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)", gap: 4 })
screen.open()- Без
detents()шторка ростом с содержимое (не выше экрана — длинный список заверни вUIScrollableвнутри). С.detents([0.3, 0.6, 1])— карта-стайл: позиции в долях высоты экрана, палец переключает между ними,setDetent(i)— из кода,onDetentChange— событие. - Свайп ниже нижней позиции закрывает шторку; с
dismissible(false)она вместо этого схлопывается до нижней — постоянная панель как в картах. - Строка-«хэндл» сверху — просто узкая
UIColumn, а не элемент SDK. - Как и любой виджет — один экземпляр на уровне модуля; строки внутри привязаны к сигналу
repeat, поэтому галочка переезжает сама.
Меню у кнопки
Три действия у строки — не повод для шторки на пол-экрана. UIPopover — модалка с прозрачной подложкой (тап мимо закрывает, под ней ничего не прокручивается) и позицией от якоря: menu.show(button) ставит меню под кнопкой, а если внизу нет места — над ней, и всегда в пределах экрана. Из-за длинного нажатия можно открыть в точке пальца: menu.show({ x: ev.clientX, y: ev.clientY }).
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })
type Habit = { id: number; title: string; color: string }
const habits = signal<Habit[]>([
{ id: 1, title: "Утренняя пробежка", color: "#34c759" },
{ id: 2, title: "Прочитать 20 страниц", color: "#ff9f0a" },
{ id: 3, title: "Растяжка", color: "#af6bff" },
])
let target: Habit | null = null // строка, для которой открыто меню
const Item = (label: string, color: string, action: (h: Habit) => void) =>
UIButton(UIText(label).style({ fontSize: 15, color }))
.style({ height: 44, px: 14, justifyContent: "flex-start", $pressed: { bgColor: colors.line } })
.onClick(() => { menu.hide(); if (target) action(target) })
const menu = UIPopover(
Item("Дублировать", "#ffffff", (h) => {
habits.value = [...habits.value, { ...h, id: Date.now(), title: `${h.title} (копия)` }]
}),
Item("Наверх", "#ffffff", (h) => {
habits.value = [h, ...habits.value.filter((x) => x !== h)]
}),
Item("Удалить", colors.danger, (h) => {
habits.value = habits.value.filter((x) => x !== h)
toast("Привычка удалена")
}),
).style({ width: 200, borderRadius: 12, bgColor: colors.card, border: `1px solid ${colors.line}`, py: 4 })
const HabitRow = (h: Habit) => {
const more = UIButton(UIText("⋯").style({ fontSize: 22, color: colors.muted }))
.style({ width: 36, height: 36, borderRadius: 18, $pressed: { bgColor: colors.line } })
.onClick(() => { target = h; menu.show(more) }) // якорь — сама кнопка
return UIRow(
UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color }),
UIText(h.title).style({ fontSize: 16, fontWeight: 600, flexGrow: 1 }),
more,
).style({ bgColor: colors.card, borderRadius: 16, p: 14, pr: 8, gap: 12, alignItems: "center" })
}
const screen = UIScreen(
UIText("Привычки").style({ fontSize: 32, fontWeight: 800 }),
UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
screen.open()- Позиция вычисляется один раз при показе — меню не следует за якорем; это платформенная норма (и причина, по которой подложка блокирует прокрутку).
- Кнопка «⋯» захвачена в переменную
more, чтобы передать её вshow(more);target— обычная переменная: меню одно, а строка меняется перед каждым показом. toast("…")— нативное всплывающее сообщение хоста: одна строка, без стилей, исчезает само. Для «отменить удаление» уже нужен виджет с кнопкой.- Длинное нажатие —
button.onLongPress(ev => menu.show({ x: ev.clientX, y: ev.clientY })); на реальных хостах оно глотает последующий клик, так чтоonClickиonLongPressуживаются на одной кнопке.
Экран целиком
Три оверлея в одном экране трекера: меню «⋯» у строки, из него — шторка «Повторять» и диалог удаления, тост после действий:
const colors = theme({ bg: "#0e0e12", card: "#1a1a20", line: "#2a2a33", muted: "#8a8a93", accent: "#FF4032", danger: "#ff6b6b" })
theme({ color: "#ffffff" })
type Habit = { id: number; title: string; color: string; repeat: string }
const habits = signal<Habit[]>([
{ id: 1, title: "Утренняя пробежка", color: "#34c759", repeat: "Каждый день" },
{ id: 2, title: "Прочитать 20 страниц", color: "#ff9f0a", repeat: "По будням" },
{ id: 3, title: "Растяжка", color: "#af6bff", repeat: "Каждый день" },
])
const target = signal<Habit | null>(null)
const patch = (h: Habit, changes: Partial<Habit>) => {
habits.value = habits.value.map((x) => (x === h ? { ...x, ...changes } : x))
}
const OPTIONS = ["Каждый день", "По будням", "По выходным"]
const sheet = UIBottomSheet(
UIColumn().style({ width: 44, height: 5, borderRadius: 3, bgColor: colors.line, alignSelf: "center", mb: 10 }),
UIText("Повторять").style({ fontSize: 16, fontWeight: 700, textAlign: "center", mb: 6 }),
OPTIONS.map((label) =>
UIButton(UIText(label).style({ fontSize: 16, flexGrow: 1, color: () => (target.value?.repeat === label ? colors.accent : "#ffffff") }))
.style({ height: 52, px: 16, borderRadius: 12, $pressed: { bgColor: colors.line } })
.onClick(() => { if (target.value) patch(target.value, { repeat: label }); sheet.hide() }),
),
).style({ bgColor: colors.card, borderRadius: 20, pt: 12, px: 12, pb: "max(safe-bottom, 20px)" })
const confirm = UIModal(
UIText("Удалить привычку?").style({ fontSize: 18, fontWeight: 700 }),
UIText(() => `«${target.value?.title ?? ""}» исчезнет вместе с историей.`).style({ color: colors.muted, fontSize: 14 }),
UIRow(
UIButton(UIText("Отмена").style({ fontWeight: 600 })).style({ flex: 1, height: 44, borderRadius: 12, bgColor: colors.line }).onClick(() => confirm.hide()),
UIButton(UIText("Удалить").style({ fontWeight: 600 })).style({ flex: 1, height: 44, borderRadius: 12, bgColor: colors.danger }).onClick(() => {
habits.value = habits.value.filter((h) => h !== target.value)
confirm.hide()
toast("Привычка удалена")
}),
).style({ gap: 10, mt: 8 }),
).style({ left: 24, right: 24, top: "36%", p: 20, gap: 8, borderRadius: 20, bgColor: colors.card })
const Item = (label: string, color: string, action: () => void) =>
UIButton(UIText(label).style({ fontSize: 15, color }))
.style({ height: 44, px: 14, justifyContent: "flex-start", $pressed: { bgColor: colors.line } })
.onClick(() => { menu.hide(); action() })
const menu = UIPopover(
Item("Повторять…", "#ffffff", () => sheet.show()),
Item("Дублировать", "#ffffff", () => {
const h = target.value
if (h) { habits.value = [...habits.value, { ...h, id: Date.now(), title: `${h.title} (копия)` }]; toast("Скопировано") }
}),
Item("Удалить", colors.danger, () => confirm.show()),
).style({ width: 200, borderRadius: 12, bgColor: colors.card, border: `1px solid ${colors.line}`, py: 4 })
const HabitRow = (h: Habit) => {
const more = UIButton(UIText("⋯").style({ fontSize: 22, color: colors.muted }))
.style({ width: 36, height: 36, borderRadius: 18, $pressed: { bgColor: colors.line } })
.onClick(() => { target.value = h; menu.show(more) })
return UIRow(
UIColumn().style({ width: 10, height: 10, borderRadius: 5, bgColor: h.color }),
UIColumn(
UIText(h.title).style({ fontSize: 16, fontWeight: 600 }),
UIText(h.repeat).style({ color: colors.muted, fontSize: 13 }),
).style({ gap: 2, flexGrow: 1 }),
more,
).style({ bgColor: colors.card, borderRadius: 16, p: 14, pr: 8, gap: 12, alignItems: "center" })
}
const screen = UIScreen(
UIText("Привычки").style({ fontSize: 32, fontWeight: 800 }),
UIColumn(() => habits.value.map(HabitRow)).style({ gap: 10, mt: 12 }),
).style({ bgColor: colors.bg, p: 20, pt: "max(safe-top, 24px)" })
screen.open()Все три оверлея созданы один раз и делят один сигнал target — «строка, над которой сейчас работаем». Меню открывает шторку или диалог, закрывшись само: у каждого слоя своя подложка, и на экране всегда ровно один. Обрати внимание, что здесь target — сигнал, а не переменная, потому что к нему привязаны тексты диалога и галочка в шторке.
Что ты узнал
UIWidget— слой над страницей: создаётся один раз,show()/hide(),overlayColorделает его модальным,attachTo(owner)привязывает к странице.UIModal— диалог с подложкой, анимацией и закрытием по тапу мимо / «назад» из коробки;dismissible(false)для обязательного выбора,transition()для своей позы.UIBottomSheet— шторка по содержимому или сdetents(); свайп закрывает.UIPopover— меню от якоря:show(button)илиshow({ x, y }); позиция считается один раз.toast()— нативное сообщение без стилей; содержимое оверлеев привязывай к сигналу «текущая цель», а не пересоздавай виджеты.
Справочник: UIWidget, UIModal, UIPopover, UIBottomSheet · Тост · События указателя.
Дальше: полировка — анимации появления, отклик на нажатие, безопасные зоны и всё, что отличает «работает» от «приятно».