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

Типизированные атрибуты для визуальной шкалы

Каждая карточка уже показывает редакционную длительность текстом. Теперь используем то же значение для небольшой декоративной шкалы. Typed attr() позволяет получить атрибут как число и подставить его в CSS-выражение. Содержательная подпись «20 минут» остаётся самостоятельной; визуальная полоса не становится единственным источником данных.

В HTML число хранится в data-minutes. Атрибут записан у самой карточки, где вычисляется ширина её псевдоэлемента. Это важно для понимания attr: значение читается у элемента, к которому относится декларация, а не произвольно у любой записи страницы. Если перенести выражение на общий контейнер, он не получит автоматически длительность своего первого потомка.

Атрибут как дополнительное числовое значение

Традиционное применение attr() в content возвращает текст для подписи. Здесь задаётся тип type(<number>), а запасное значение равно двадцати. Число умножается на один процент. В маленьком примере одна минута даёт один процент ширины, затем clamp ограничивает результат от нуля до ста. Это выбранная визуальная условность, а не нормализация по максимальному уроку.

Смысл шкалы нужно назвать честно. Материал на тридцать пять минут получит более длинную полосу, чем материал на двадцать минут. Но полоса не показывает уже пройденную часть урока, сложность и фактическое время конкретного посетителя. Такие значения потребовали бы другой модели данных. Смешивание названий «длительность» и «прогресс» создаёт убедительную, но неправильную интерпретацию.

В базовой ветке полоса имеет фиксированную длину и играет только декоративную роль. Числовой синтаксис включается через @supports с используемым выражением. Не нужно удалять текст, если браузер успешно распознал typed attr. Текстовая форма важна для чтения и остаётся правильной при любом отображении псевдоэлемента.

Если атрибут отсутствует либо его содержимое не соответствует ожидаемому типу, вступает предусмотренное запасное значение. Это не замена проверке исходных данных. Например, отрицательная длительность может быть числом с точки зрения парсера, но неправильным редакционным значением. clamp предотвращает отрицательную ширину, однако не исправляет ошибочную подпись «минус пять минут».

Не используем значение атрибута для построения URL. У attr() есть ограничения безопасности и особенности вычисления значений, которые не следует обходить ради динамического ресурса. Наша задача ограничена числом и геометрией. Ссылки статьи по-прежнему задаются обычным href, прошедшим договор маршрутизации.

Псевдоэлемент не создаёт новый узел, который надо добавлять JavaScript для каждой карточки. Это уменьшает количество декоративной разметки, но не отменяет правил доступности: существенная информация должна существовать вне созданного CSS-содержимого. Именно поэтому исходный абзац с длительностью сохраняется неизменным.

Ожидаемое отличие этапа — разные длины дополнительных полос у трёх карточек при поддержке нового синтаксиса. При будущем просмотре замените одно число строкой, затем уберите атрибут. Так становится заметно, что fallback относится к CSS-значению, а не автоматически к тексту карточки. После опыта верните согласованные данные, иначе учебный пример будет демонстрировать два противоречащих значения.

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

.card::after { content: ''; display: block; height: .2rem; width: 30%; background: #76638e; margin-block-start: .7rem; }
@supports (width: calc(attr(data-minutes type(<number>), 20) * 1%)) {
  .card::after { width: clamp(0%, calc(attr(data-minutes type(<number>), 20) * 1%), 100%); }
}

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

Значение в CSS и подпись в HTML должны приходить из согласованных данных. Сейчас они записаны вручную ради небольшого примера. При генерации библиотеки лучше формировать оба из одного поля Markdown. Иначе автор может изменить двадцать минут в подписи и забыть data-minutes. Результат будет технически допустимым, но смысл двух представлений разойдётся.

Typed attr задаёт тип для вычисления, а не весь контракт редакционного значения. Неотрицательность, верхний предел и единица измерения остаются правилами исходных данных. Например, секунды и минуты могут быть одинаковыми числами для парсера, но разными величинами для человека. Перед добавлением новой шкалы следует определить единицу и подпись, а не только красивый коэффициент ширины.

Для самостоятельного варианта задайте длительность больше ста, затем отрицательное число и нечисловую строку. Рассмотрите отдельно подпись и геометрию полосы. Цель не получить одинаковую картинку во всех ошибочных случаях, а понять, где срабатывает типизация, где ограничение CSS и где требуется исправление содержания. После опыта верните три исходных числа, сохранив понятный набор данных для следующих этапов.

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

Fallback typed attr применяется при невозможности получить значение нужного типа. Это не то же самое, что запасной цвет при отсутствии всего свойства. В этом уроке существуют обе границы: неизвестный синтаксис пропускает @supports, а неправильный атрибут обрабатывается внутри поддержанного выражения. Разделяйте эти случаи в объяснении, иначе поведение ошибочной записи останется непонятным.

Ограничение clamp задаёт допустимую геометрию, не редакционную точность. Материал на сто двадцать минут и материал на двести минут дадут одинаковую максимальную полосу. Поэтому для сравнения больших длительностей этот дизайн недостаточен. Если такая задача станет важной, нужно выбрать другую шкалу и подпись, а не считать насыщение ширины точной визуализацией данных.

Когда число хранится в Markdown, генератор должен проверить его как число до создания HTML. CSS-парсер получает уже доставленные атрибуты и не заменяет всю редакционную схему. Текущий ручной набор показывает механику; следующая команда может подключить его к настоящей коллекции. При этом единственный источник длительности должен формировать и подпись, и data-атрибут одновременно.

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

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

Условие Ожидаемое состояние и его граница
Число равно 20 Выражение переводит его в двадцать процентов ширины декоративного элемента.
Число равно 35 Полоса длиннее, однако не обозначает прочитанный процент.
Атрибут отсутствует Предусмотрено запасное числовое значение в typed attr.
В атрибуте слово Несовпадение с типом нельзя выдавать за правильные редакционные данные.
Число больше ста Clamp ограничивает геометрию, текстовая длительность остаётся отдельным значением.
Синтаксис не поддержан Остаётся базовая декоративная длина и точная текстовая подпись.

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

Первичные источники: Typed attr.

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