Переходы между страницами каталога
До этого эффект работал внутри одного документа. Теперь свяжем каталог и страницу материалов переходом между документами. Каждая страница по-прежнему имеет собственный HTML и обычные ссылки. Нам не нужен клиентский роутер, который перехватывает переходы и заменяет всю разметку. Цель — дополнить стандартную навигацию визуальной связью там, где браузер поддерживает её.
Директива @view-transition { navigation: auto; } задаёт согласие документа на переходы такого вида. Правило находится в общем styles.css, который подключён к каталогу и статье. Для cross-document перехода обе стороны должны участвовать в нужном договоре. Добавление правила только на исходную страницу не означает, что любой внешний сайт станет частью той же анимации.
Переход, который сохраняет обычную навигацию
Сценарий не вызывает startViewTransition при нажатии ссылок статьи. Этот метод относился к предыдущей задаче. Здесь переход запускается самой навигацией браузера при выполнении условий механизма. Такое различие предотвращает лишнюю попытку построить собственную подмену документов поверх обычных действий и истории.
Общий хедер получает устойчивое имя catalog-header. В каждом документе одновременно существует один элемент с этим именем. Браузер может связать его прежнее и новое представления. Мы не задаём такое имя всей группе заголовков или нескольким ссылкам. Если одноимённые элементы дублируются, визуальная связь перестаёт быть однозначной.
Обычная ссылка сохраняет href="article.html#markdown". Фрагмент — часть договора о месте чтения в документе. Анимация не должна заменять правильный переход на произвольное положение прокрутки. В реальном просмотре следует проверить и адрес, и конечный раздел. Если эффект красиво завершился, а ссылка привела к другому содержимому, задача не выполнена.
Переходы ограничены условиями same-origin и поддержкой браузера. Документы с другого домена не получают общие снимки благодаря одному похожему CSS-файлу. При запуске через file: свойства происхождения и навигации отличаются от HTTP. Поэтому серия описывает будущий просмотр через локальный сервер, не обещая одинаковое поведение при двойном щелчке на любом файле.
Страница материалов содержит все три целевых раздела и ссылку возвращения. Это минимальный связный пример, а не полная система маршрутов. Названия карточек, идентификаторы разделов и обратная ссылка согласованы. Когда каталог будет генерироваться из данных, те же условия нужно выразить в шаблоне, иначе эффект станет маскировать обычные ошибки адресации.
Отключённая поддержка правила возвращает мгновенную стандартную навигацию. Не следует показывать предупреждение посетителю лишь потому, что браузер не анимировал переход. Он получил урок по рабочей ссылке — основная задача выполнена. Аналогично настройки движения пользователя имеют приоритет над желанием сохранить декоративную непрерывность.
Наблюдаемый результат будущего просмотра — переход из каталога к разделу статьи с сохранением её адреса, истории и обратного действия. Начните с обычного Enter на ссылке, затем рассмотрите возвращение назад и открытие в новой вкладке. Эти действия важнее одинаковой продолжительности эффекта на разных устройствах. Урок не утверждает, что такие наблюдения уже выполнены при подготовке исходников.
Полный файл index.html текущего этапа:
<!doctype html>
<html lang="ru"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Учебный каталог</title><link rel="stylesheet" href="styles.css"><script type="module" src="app.js"></script></head>
<body><a href="#main">Перейти к материалам</a><header><a href="index.html">Учебный каталог</a></header><main id="main"><h1>Учебные материалы</h1><p>Каталог «Полка»: статьи о контенте и интерфейсах.</p><button type="button" id="open-lesson" hidden>О проекте урока</button>
<dialog id="lesson-dialog" aria-labelledby="dialog-title"><h2 id="dialog-title">Учебная библиотека</h2><p>Три урока с постоянными ссылками. Карточка дополняет каталог.</p><form method="dialog"><button value="close" autofocus>Закрыть</button></form></dialog><button type="button" id="help-button" popovertarget="help" hidden>Как читать каталог</button>
<aside id="help"><h2>Чтение каталога</h2><p>Откройте название урока. Справка не закрывает доступ к другим ссылкам.</p></aside><nav id="filters" aria-label="Фильтр уроков" hidden><button type="button" data-filter="all" aria-pressed="true">Все</button> <button type="button" data-filter="content" aria-pressed="false">Контент</button> <button type="button" data-filter="interface" aria-pressed="false">Интерфейс</button></nav><p id="filter-status" role="status"></p><ul class="cards"><li class="card" data-topic="content" data-minutes="20"><h2><a href="article.html#markdown">Markdown и структура урока</a></h2><p class="card-description">Разделяем содержание и оформление библиотеки.</p><p class="card-meta">Контент · 20 минут</p></li>
<li class="card" data-topic="interface" data-minutes="35"><h2><a href="article.html#navigation">Навигация учебного каталога</a></h2><p class="card-description">Сохраняем обычные ссылки и последовательность чтения.</p><p class="card-meta">Интерфейс · 35 минут</p></li>
<li class="card" data-topic="content" data-minutes="25"><h2><a href="article.html#urls">Постоянные адреса материалов</a></h2><p class="card-description">Меняем каталог, сохраняя адрес каждой статьи.</p><p class="card-meta">Контент · 25 минут</p></li></ul><p class="notice">Каждый урок доступен по обычной ссылке. Фильтры и эффекты дополняют чтение.</p></main><footer>Полка · учебный проект</footer></body></html>
Полный файл app.js текущего этапа:
document.documentElement.dataset.enhanced = 'true';
const lessonDialog = document.querySelector('#lesson-dialog');
const openLesson = document.querySelector('#open-lesson');
if (typeof lessonDialog.showModal === 'function') {
openLesson.hidden = false;
openLesson.addEventListener('click', () => {
if (!lessonDialog.open) lessonDialog.showModal();
});
lessonDialog.addEventListener('close', () => openLesson.focus());
}
if ('popover' in HTMLElement.prototype) {
document.querySelector('#help').setAttribute('popover', 'auto');
document.querySelector('#help-button').hidden = false;
}
const filters = document.querySelector('#filters');
filters.hidden = false;
const applyFilter = (topic) => {
let count = 0;
document.querySelectorAll('.card').forEach((card) => {
card.hidden = topic !== 'all' && card.dataset.topic !== topic;
if (!card.hidden) count += 1;
});
filters.querySelectorAll('button').forEach((button) => button.setAttribute('aria-pressed', String(button.dataset.filter === topic)));
document.querySelector('#filter-status').textContent = `Показано уроков: ${count}`;
};
let transitionBusy = false;
filters.addEventListener('click', async (event) => {
const button = event.target.closest('button[data-filter]');
if (!button || transitionBusy) return;
const topic = button.dataset.filter;
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!document.startViewTransition || reduceMotion) { applyFilter(topic); return; }
transitionBusy = true;
try {
const transition = document.startViewTransition(() => applyFilter(topic));
await transition.finished;
} catch { applyFilter(topic); }
finally { transitionBusy = false; }
});
Полный файл styles.css текущего этапа:
* { box-sizing: border-box; }
:root { color-scheme: light; --accent: #6d28d9; --paper: #fff; --ink: #20202a; }
body { margin: 0; color: var(--ink); background: #f7f6fb; font: 1rem/1.6 system-ui, sans-serif; }
header, main, footer { max-width: 72rem; margin-inline: auto; padding: 1.5rem; }
a { color: #5923b5; text-underline-offset: .18em; }
button { font: inherit; padding: .5rem .9rem; cursor: pointer; }
:focus-visible { outline: 3px solid #17693d; outline-offset: 4px; }
[hidden] { display: none !important; }
.cards { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr)); list-style: none; padding: 0; }
.card, aside { padding: 1.25rem; border: 1px solid #ddd8e8; border-radius: .7rem; background: white; }
.card h2 { margin-block-start: 0; }
.card-meta { color: #51495f; }
.notice { border-inline-start: .25rem solid var(--accent); padding-inline-start: 1rem; }
dialog { width: min(36rem, calc(100vw - 2rem)); border: 1px solid #b9afca; padding: 1.5rem; border-radius: .8rem; }
dialog::backdrop { background: rgb(20 15 30 / .5); }
@supports (anchor-name: --catalog-help) and (position-area: bottom) {
#help-button { anchor-name: --catalog-help; }
#help[popover] { position: fixed; position-anchor: --catalog-help; position-area: bottom; inset: auto; margin: .5rem; width: min(25rem, calc(100vw - 2rem)); }
}
@supports (position-try-fallbacks: flip-block) {
#help[popover] { position-try-fallbacks: flip-block, flip-inline, flip-block flip-inline; max-block-size: calc(100dvh - 2rem); overflow: auto; }
}
.cards { view-transition-name: catalog-list; }
::view-transition-old(catalog-list), ::view-transition-new(catalog-list) { animation-duration: 180ms; }
@view-transition { navigation: auto; }
header { view-transition-name: catalog-header; }
Разбор изменения каталога
Хедер двух документов имеет одинаковый смысл и устойчивое имя, но его текст не обязан быть побайтно идентичным для любого будущего проекта. Если одна сторона показывает другой набор действий, визуальный переход может вводить в заблуждение. Связывать следует элементы, которые действительно представляют одну роль, а не просто похожие прямоугольники.
В документе статьи не подключён app.js, потому что в нём нет кнопок каталога. Общие стили этого не требуют. Такой выбор показывает, что cross-document эффект не является результатом запуска одинакового управляющего сценария на обеих страницах. Не переносите обработчики фильтра туда, где нет ожидаемых элементов: это создаст обычные ошибки querySelector и затруднит чтение журнала.
Для самостоятельного варианта измените надпись статьи, сохранив имя хедера, и рассмотрите переход. Затем временно исключите правило opt-in из общего CSS. Обычная ссылка должна продолжать открывать тот же фрагмент. Такой опыт помогает проверить главный договор адресации независимо от эффекта. История, прямой URL и возможность новой вкладки остаются критериями, которые не следует приносить в жертву непрерывной анимации.
Переход к фрагменту внутри уже открытого документа и переход к другому HTML могут иметь разную модель навигации. Не смешивайте их в журнале наблюдений как одно действие. В карточках каталога назван отдельный документ и его section, поэтому сначала важен факт загрузки нужной страницы, затем положение чтения. Собственный обработчик не должен переписывать эту пару без необходимости.
При восстановлении истории браузер может использовать сохранённое состояние документа. Это отдельная возможность платформы, а не признак того, что наша статья стала одностраничным приложением. Не сбрасывайте прокрутку глобальным сценарным вызовом при каждом событии загрузки только ради единообразия эффекта. Такое действие способно нарушить ожидаемое возвращение к ранее выбранной позиции.
Если общий хедер зависит от текущего раздела, его оформление следует согласовать на обеих страницах. Нельзя скрывать важную ссылку на одной стороне лишь ради визуальной непрерывности. У эффекта нет права менять основную навигацию. Посетитель должен понимать, где оказался и как вернуться, даже когда снимки вообще не создаются.
Для реальной многодокументной библиотеки полезно проверить переходы между несколькими видами страниц: каталогом, темой и статьёй. В этой серии показаны только два документа, поэтому её выводы не обещают охват любой будущей оболочки. Расширение набора страниц требует пересмотреть уникальные имена и opt-in в каждом соответствующем layout.
Условия для самостоятельного наблюдения
Это план будущего разбора, а не журнал выполненных проверок. Рассмотрите одинаковые исходные данные и сопоставьте действие с ожидаемым состоянием.
| Условие | Ожидаемое состояние и его граница |
|---|---|
| Обычный переход | Браузер загружает самостоятельный article.html, а не заменяет DOM клиентским роутером. |
| Есть нужный фрагмент | Конечный адрес указывает на конкретный раздел материала. |
| Вторая сторона без opt-in | Нельзя считать общий эффект гарантированным только по исходной странице. |
| Другой origin | Похожие имена и стили не создают разрешение на междоменный переход. |
| Открытие новой вкладки | Сохраняется стандартное действие ссылки, отдельный эффект не требуется. |
| Возврат по истории | Маршрут остаётся частью браузерной истории, его не имитирует самописный стек. |
Оглавление серии, курсы ProfessorWeb и полный архив исходников. Для этого урока выберите lesson-07.
Первичные источники: View Transition API.
Редакционный статус: локальный черновик. Установка, учебные программы, браузерные сценарии, тесты и публикация не выполнялись. Описанные эффекты ожидаемые.