Возврат между экранами
У действия сохранения появился самостоятельный жизненный цикл. Теперь согласуем переход между каталогом и выбранным курсом. У Telegram есть BackButton в области хоста, но сама кнопка не знает наши экраны и не хранит черновик плана. Результат урока — маленькая навигационная модель из двух состояний, в которой возврат меняет маршрут и сохраняет отдельно принадлежащие приложению данные.
Начальный экран серии является одним документом. Для двух экранов можно использовать отдельные секции того же DOM, не вводя полноценный router и сборщик. Важнее определить смысл маршрута: catalog показывает выбор, course — детали одного известного курса. Личный план, draft и серверная version не помещаются в DOM как единственный источник. Если возврат уничтожает секцию, данные должны продолжать жить в модели приложения и появиться после повторного открытия.
Маршрут без потери черновика
Создадим самостоятельный модуль navigation.js. Он принимает host и функцию render, которая отвечает за отображение переданного маршрута. Набор ключей соответствует четырём курсам модели. Это полный модуль, не уже подключённый двухэкранный сервис; базовая читательная оболочка не меняется автоматически от присутствия файла в архиве.
export function createNavigation({host, render}) {
const courses = new Set(['javascript', 'html-css', 'markdown', 'performance']);
const native = host?.BackButton;
const capable = native && typeof host.isVersionAtLeast === 'function' &&
host.isVersionAtLeast('6.1') && ['onClick', 'offClick', 'show', 'hide']
.every(name => typeof native[name] === 'function');
let route = Object.freeze({name: 'catalog'});
let disposed = false;
function paint() {
if (disposed) return;
render(route);
if (capable) route.name === 'catalog' ? native.hide() : native.show();
}
function back() {
if (disposed || route.name === 'catalog') return;
route = Object.freeze({name: 'catalog'});
paint();
}
if (capable) native.onClick(back);
paint();
return {
openCourse(courseId) {
if (disposed || !courses.has(courseId)) return false;
route = Object.freeze({name: 'course', courseId});
paint();
return true;
},
back,
current: () => route,
dispose() {
disposed = true;
if (capable) { native.offClick(back); native.hide(); }
}
};
}
Render получает только маршрут и не обязан очищать draft. Для подключения приложение хранит черновик отдельно, например объект {courseId, sessionsPerWeek} вне функции. При openCourse открывается нужное представление, при back оно заменяется каталогом. Обе операции не считаются сохранением и не отправляют запрос. Признак dirty остаётся результатом сравнения с подтверждённым планом, а не исчезает от того, что секция формы временно скрылась.
Мы не создаём произвольную историю переходов. У двух состояний понятен один шаг назад: из course в catalog. На корневом экране BackButton скрыта. Если появятся вложенные экраны настройки и предпросмотра, понадобится расширенная модель стека либо URL-маршрутов. Не добавляйте несколько callback, каждый из которых по-своему считает текущий экран. Иначе один клик может одновременно закрыть детали, изменить форму и выйти из приложения.
Кнопка хоста и кнопка документа
Методы BackButton описаны в официальном справочнике. У обычного браузера этого механизма может не быть, поэтому представление course содержит обычную кнопку «К каталогу», вызывающую тот же navigation.back. Так два способа взаимодействия используют одну операцию. Запасная кнопка не должна вручную прятать секции другим алгоритмом: внешний маршрут и видимая страница тогда расходятся.
Не подменяем системную историю браузера без необходимости. history.back() отвечает за переход страницы, а не за наше конкретное состояние route. Если человек пришёл по внешней ссылке, такой вызов может вывести его из mini app, когда он ожидал вернуться к каталогу. Наш модуль хранит внутреннее состояние и не обещает интеграцию с browser history, deep links или кнопкой закрытия Telegram. Каждая из этих границ имеет отдельное назначение.
Сохранение и переход одновременно
Если сохранение ещё идёт, навигация должна иметь согласованное правило. Наш модуль не отменяет серверную операцию и не выбирает этот договор за экран. Приложение может оставить текущую форму до ответа либо позволить переход, сохранив идентификатор операции и последующее чтение состояния. В примере bindCommit после dispose не меняет старое сообщение, но сервер мог завершить запись. Поэтому при возвращении к форме нельзя уверенно восстановить состояние из старого текста «сохраняем».
Несохранённый выбор в памяти также не переживает закрытие страницы. BackButton помогает перемещаться внутри открытого интерфейса, а не является механизмом постоянного хранения. В уроках о состоянии и storage решим, какие несекретные значения можно сохранять отдельно. Пока маршрут назад сохраняет только данные уже живущего приложения. Это честное ограничение: повторное полное открытие не обязано показать прежнюю частоту занятий без серверного чтения или подготовленного draft storage.
Управление фокусом
После перехода на course читателю нужно понять новое место. Render должен обновить видимый заголовок и перевести фокус к подходящему началу экрана, если это необходимо сценарию. При возврате полезно вернуть фокус к исходному выбранному курсу, а не к невидимой кнопке старой секции. Модуль принимает render как зависимость, поэтому не содержит произвольный selector конкретного DOM. Полная доступность представления должна проверяться вместе с реализацией render, не по одному наличию native BackButton.
Рассмотрим ошибку: курс скрыли CSS, но его поле осталось доступно Tab. Человек на каталоге попадает в несуществующую видимую форму. Для скрытой секции используйте механизм, который действительно исключает её взаимодействие, и проверьте фокус после смены. Простое opacity: 0 не делает экран недоступным клавиатуре. Это особенно заметно в WebView, где визуальная область невелика и случайный фокус способен открыть клавиатуру поверх другого экрана.
После смены экрана не создавайте новую подписку на BackButton внутри каждого render. В нашем модуле обработчик устанавливается один раз на срок жизни навигации. Повторный render меняет представление, но сохраняет ту же модель маршрута и тот же callback. Это позволяет отдельно пересоздать форму после изменения темы или получения плана: внешний возврат продолжит действовать один раз, а старый DOM не будет удерживаться лишней подпиской.
Ошибка маршрута и очищение подписки
Попробуйте openCourse с неизвестным ключом. Модуль возвращает false и не меняет route. Не стоит показывать пустой экран с заголовком «undefined», считая любой параметр URL известным курсом. Позднее deep links будут использовать тот же ограниченный набор. Если курс удалён из каталога, обработчик покажет понятное отсутствие и оставит возможность вернуться, а не создаст собственную карточку на основе непроверенной строки.
Ожидаемый результат — один маршрут course, возврат в catalog, скрытая native BackButton на корне и независимый draft. Реальные переходы, фокус, Telegram и сценарии JavaScript здесь не запускались. После dispose callback снимается той же ссылкой; последующие методы не меняют экран. Следующий урок рассмотрит данные запуска, которые могут выбрать начальный курс, но пока ещё не должны определять владельца или разрешать сохранение личного плана.