Перейти к содержанию

Плотность карточек через запросы стиля

Добавим выбор компактного описания карточек. Размер шрифта будет меняться не из-за ширины окна, а из-за состояния области каталога. Для нового слоя используем container style query по custom property. В проекте одновременно останется обычный селектор по атрибуту, поэтому выбранная плотность имеет осмысленное оформление и без этого синтаксиса.

Ранее сетка меняла число колонок в зависимости от доступной ширины. Теперь решаем другую задачу: пользователь выбирает плотность представления. При одинаковой ширине экрана два каталога могут иметь разные настройки. Размерный container query не выражает такое намерение. Именованная переменная --density передаёт смысл, который компонент использует при оформлении своих потомков.

Семантическая плотность области

Контейнер .cards получает container-name: catalog и начальное значение --density: comfortable. Значение не является встроенным режимом браузера. Это наш словарь между родителем и дочерними элементами. При включённом флажке сценарий назначает data-density="compact", а соответствующее CSS-правило меняет custom property. Так состояние остаётся видимым и в разметке, и в стиле.

В запросе @container catalog style(--density: compact) проверяется вычисленное значение переменной контейнера. Правила внутри применяются к потомкам. Они не являются способом условно стилизовать сам queried container по собственному изменению. Это ограничение предотвращает некоторые циклы, когда условие и результат постоянно меняли бы друг друга.

Не утверждаем, что сейчас можно свободно запросить любое обычное свойство вроде display или color во всех браузерах. Практический пример использует custom property. Поддержка syntax и разновидностей запросов развивается отдельно, поэтому следует сверять именно используемую ветку. Похожие названия size, style и scroll-state не означают одинаковый уровень доступности.

Обычный селектор .cards[data-density="compact"] .card-description задаёт меньший шрифт вне container query. Это запасное оформление. Запрос дополнительно сокращает отступ после заголовка. Таким образом базовая ветка передаёт выбранную плотность, а новая возможность уточняет её в области контейнера. Отсутствие поддержки не возвращает элементы управления в бессмысленное состояние.

Сценарий не перебирает каждую карточку для назначения inline-стиля. Он меняет один признак на родительской области. Новая запись, добавленная в список, подчинится тем же правилам. Это полезно при росте библиотеки: количество DOM-элементов не превращается в количество ручных назначений стиля после каждого действия.

Компактность не убирает описание и не уменьшает текст до произвольной трудно читаемой величины. Выбор должен отвечать задаче обзора, а не только помещению большего числа карточек. При масштабировании браузера и длинных названиях оба режима сохраняют порядок чтения. Изменение плотности может изменить высоту документа и шкалу прокрутки, что естественно для уже добавленных эффектов.

Ожидаемый результат — более компактное описание после включения флажка, а при поддержке style query ещё и уточнение отступов. Сравните два значения переменной на том же списке. Если позже появится тёмная область или особый вид карточек, не перегружайте --density несколькими значениями смысла. Для темы, состояния загрузки и плотности лучше определить независимые договоры.

Полный файл 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><div class="reading-progress" aria-hidden="true"></div><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><fieldset id="density-control" hidden><legend>Плотность карточек</legend><label><input type="checkbox" id="compact"> Компактное описание</label></fieldset><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; }
});
document.querySelector('#density-control').hidden = false;
document.querySelector('#compact').addEventListener('change', (event) => {
  document.querySelector('.cards').dataset.density = event.target.checked ? 'compact' : 'comfortable';
});

Полный файл 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; }

.reading-progress { display: none; }
@supports (animation-timeline: scroll()) {
  .reading-progress { display: block; position: fixed; inset: 0 0 auto; height: .2rem; background: #17693d; transform-origin: left; animation: reading linear; animation-timeline: scroll(root block); z-index: 1; }
  @keyframes reading { from { transform: scaleX(0); } to { transform: scaleX(1); } }
}

@supports (animation-timeline: view()) {
  .card { animation: reveal linear both; animation-timeline: view(); animation-range: entry 0% entry 100%; }
  @keyframes reveal { from { opacity: .65; transform: translateY(.4rem); } to { opacity: 1; transform: none; } }
}

@media (prefers-reduced-motion: reduce) {
  .card, .reading-progress { animation: none; transform: none; opacity: 1; }
  .reading-progress { display: none; }
  ::view-transition-old(*), ::view-transition-new(*) { animation: none; }
}

.cards { --density: comfortable; container-name: catalog; }
.cards[data-density="compact"] { --density: compact; }
/* Attribute selector is the conservative fallback. */
.cards[data-density="compact"] .card-description { font-size: .9rem; }
@container catalog style(--density: compact) {
  .card-description { font-size: .9rem; }
  .card h2 { margin-block-end: .4rem; }
}

Разбор изменения каталога

Custom property наследуется, если мы не задали другую модель. Это удобно для области каталога, но может повлиять на вложенный компонент, который случайно использует такое же имя с другим смыслом. Поэтому название и область должны быть частью соглашения команды. Общие слова вроде --mode требуют особенно внимательного контроля; --density прямо выражает назначение в этом примере.

Именованный контейнер помогает ограничить поиск нужного предка. Без явного имени запрос может опереться на ближайшую подходящую область, которая появится после рефакторинга. Список всё ещё остаётся обычным DOM-предком карточек, поэтому связь читается из разметки. Не переносите запрос в отдельную область без проверки того, где находится соответствующий контейнер.

Для самостоятельного варианта добавьте рядом второй список с собственным значением density и отдельным управлением. Рассмотрите, изменяется ли только нужная область. Затем оставьте attribute fallback и временно исключите container rule. Выбранное состояние должно иметь понятное представление и без нового синтаксиса. Такой опыт показывает, почему поддержка style query является дополнительной возможностью, а состояние плотности — самостоятельным договором компонента.

Style query не является JavaScript-подпиской на изменение свойства. Он участвует в вычислении CSS и применяет декларации по условию контейнера. Поэтому не нужно посылать отдельное событие каждой карточке после изменения density. Состояние области и правила потомков уже выражают нужную связь. Если появится бизнес-действие, его следует моделировать отдельно от декоративной переменной.

Регистрировать --density через @property в этом примере не требуется. Это словесное значение выбранного режима. Регистрация имеет собственные правила типа, наследования и начального значения, которые могут изменить условия name-only запросов. Не добавляйте её ради общего убеждения, что любая custom property должна иметь регистрацию. Сначала определите пользу и допустимые значения именно этого договора.

Fallback по атрибуту не пытается воспроизвести весь запросный механизм. Он сохраняет главное отличие компактного режима. Если позже style query станет обязательным условием сложной темы, следует решить, как будет выглядеть неподдерживаемая ветка. Полная пиксельная копия не всегда нужна, но интерфейс не должен обещать переключение, которое вообще не меняет наблюдаемое состояние.

Для будущего просмотра сравните вычисленное значение переменной контейнера и стиль конкретного потомка. Если условие не сработало, причиной может быть другое имя предка или переопределённое значение. Не начинайте с изменения глобального font-size, пока не выяснили источник. Такой разбор помогает читать каскад как последовательность определённых договоров, а не набор случайных совпадений.

Условия для самостоятельного наблюдения

Это план будущего разбора, а не журнал выполненных проверок. Рассмотрите одинаковые исходные данные и сопоставьте действие с ожидаемым состоянием.

Условие Ожидаемое состояние и его граница
Флажок выключен Область использует comfortable, исходные размеры описаний сохраняются.
Флажок включён Атрибут родителя переводит custom property в compact.
Style query отсутствует Обычный атрибутный селектор всё равно уменьшает размер описаний.
Добавлена новая карточка Правило потомков применяется без регистрации отдельного обработчика.
Другая область каталога Нужно определить имя контейнера и наследование переменной, избегая случайного общего режима.
Запрошено обычное свойство Поддержку произвольных style features нельзя вывести из работоспособности custom property.

Оглавление серии, курсы ProfessorWeb и полный архив исходников. Для этого урока выберите lesson-11.

Первичные источники: Container style queries.

Редакционный статус: локальный черновик. Установка, учебные программы, браузерные сценарии, тесты и публикация не выполнялись. Описанные эффекты ожидаемые.