Главная / Статьи / Раскрывающиеся элементы React, доступные для индексации: скрытие с помощью CSS Grid, а не демонтирования

Раскрывающиеся элементы React, доступные для индексации: скрытие с помощью CSS Grid, а не демонтирования

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

1798 слов

Большинство учебников по аккордеонам отображают панель только тогда, когда она открыта. Это подходит для модальных окон, но на страницах с большим объемом контента, таких как портфолио, центр документации или FAQ, это означает, что текст в каждом закрытом разделе отсутствует в документе при обработке страницы поисковыми роботами. В этом руководстве перестраивается набор вложенных разделов, которые можно складывать, так что ничего не исчезает из DOM, высота плавно анимируется без использования «волшебных» чисел, правило «только один открытый раздел» корректно работает на любом уровне вложенности, повторное открытие раздела начинается с чистого листа, а цель клика совпадает с местом окончания текста заголовка.

Сценарий: одиннадцать разделов на трех уровнях глубины

Представьте личный сайт с одиннадцатью верхними уровнями разделов, которые можно скрыть: обо мне, доступность, опыт, портфолио, образование, языки, навыки, лаборатория, загрузки, местоположение и контакты. Некоторые из этих разделов содержат ещё более вложенные структуры. Один только раздел «Контакты» имеет три уровня глубины: публичные профили, публикации и борды вакансий каждый из которых делятся на свои подгруппы.

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

Паттерн, который практически все учебники предлагают, — это условная отрисовка:

{isOpen && (
  <div className="content">
    {children}
  </div>
)}

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

Что на самом деле делает условная загрузка

{isOpen && ...} ничего не скрывает. Когда значение isOpen равно false, React никогда не создаёт этот поддерево, поэтому в DOM нет ничего, что можно было бы скрыть или показать.

Для модалок и выпадающих меню это именно тот правильный подход: закрытый диалог не должен находиться в документе. Для страниц с контентом всё наоборот. На портфолио именно сжатые разделы являются сутью: годы опыта, описания проектов, списки технологий, обязанности и результаты. Каждый термин, который может использоваться рекрутером при поиске, находится внутри элемента, который изначально закрыт.

Google действительно выполняет JavaScript, поэтому ситуация стала менее серьёзной, чем раньше. Но страница отрисовывается в своём начальном состоянии. Парсер не нажимает на символы-стрелки. Всё, что удаляется при загрузке, с точки зрения индексации, отсутствует на странице.

Сжатие с использованием стилей вместо отрисовки

Решение заключается в том, чтобы рассматривать состояние «сжатия» как вопрос стилизации, а не решение по отображению. Контент загружается один раз и остаётся загруженным; меняется только его видимая высота.

Традиционным способом анимации высоты является использование свойства max-height, которое заставляет предполагать значение, большее, чем высота самой высокой части контента, и принимать неравномерное время анимации, поскольку переход происходит в течение всего предполагаемого диапазона, а не фактической высоты. CSS Grid предлагает более чистый подход, показанный здесь с помощью классов Tailwind:

<div
  className={`grid transition-all duration-300 ${
    isOpen ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
  }`}
>
  <div className="overflow-hidden">{children}</div>
</div>

Почему трюк с grid-rows работает

Ось сетки размером 1fr растягивается до естественной высоты своего содержимого, в то время как ось размером 0fr сжимается до нуля. Браузеры могут интерполировать значения между этими двумя состояниями, поэтому переход происходит плавно и не требует установления максимального значения. Внутренний обёрточный элемент с атрибутом overflow-hidden крайне важен: без него содержимое выйдет за пределы строки с нулевой высотой вместо того, чтобы быть обрезанным.

Теперь маркировка одинакова как в открытом, так и в закрытом состоянии. Различаются лишь вычисленная высота и прозрачность, поэтому каждое слово остаётся в документе и доступно для индексации.

Есть один недостаток: анимация параметра grid-template-rows — это относительно новая возможность браузеров. Старые версии просто переключаются между состояниями, поэтому если вам необходимо поддерживать их, нужно запланировать альтернативное решение или смириться с отсутствием анимации. Проверьте текущие данные о поддержке с учётом вашего трафика.

Исключение свёрнутого контента из порядка вкладок

Сохранение контента в DOM имеет побочный эффект с точки зрения доступности, который необходимо учитывать. Ссылки и кнопки внутри визуально свёрнутой панели всё ещё могут получать фокус с клавиатуры, а скрин-читеры могут их объявлять. Добавление атрибута inert к панели во время её закрытия (или, по крайней мере, aria-hidden вместе с деланием внутренних элементов недоступными для фокуса) позволяет сохранить текст в документе для поисковых роботов, но исключить его из возможностей взаимодействия. Сопоставьте кнопку заголовка с атрибутом aria-expanded, чтобы вспомогательные технологии знали текущее состояние. Браузер также предлагает опцию hidden="until-found", которая позволяет оставлять контент доступным для поиска с помощью функции «найти на странице»; её стоит рассмотреть, однако её стилизация и анимации отличаются от подхода с сеткой.

Ограничение «только одно открыто за раз»

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

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

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

type CollapseGroup = {
  openKey: string | null;
  setOpenKey: (key: string | null) => void;
};

Эта структура общается через контекст React, причем значение null означает «не внутри группы»:

const CollapseGroupContext = createContext<CollapseGroup | null>(null);

Каждый узел считывает контекст своего родителя, чтобы определить, является ли он открытым, затем оборачивает свои дочерние узлы в нового поставщика контекста. При трех уровнях вложенности состояние находится в трех независимых контекстах, а не в одной плоской структуре с комбинированными ключами вроде contact/profiles/boards. Это делает каждый уровень простым и позволяет достигать неограниченной глубины без дополнительного учета.

Сброс вложенного состояния при закрытии родителя

Более тонкая проблема удобства проявляется только после длительного использования страницы. Откройте раздел, затем подраздел, а потом еще один подподраздел. Закройте верхний уровень раздела и прочитайте что-то другое. Когда вы позже снова откроете этот верхний раздел, он восстановится именно до того состояния с трьмя уровнями, которое вы оставили.

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

Решение заключается в том, чтобы при закрытии узла автоматически сбрасывалось всё, что находится под ним. Провайдер контролирует открытый ключ своей группы:

const CollapseGroupProvider = ({ isOpen, children }) => {
  const [openKey, setOpenKey] = useState<string | null>(null);

а эффект сбрасывает этот ключ каждый раз, когда закрывается собственный узел провайдера:

  useEffect(() => {
    if (!isOpen) setOpenKey(null);
  }, [isOpen]);  // ...
};

Почему каскад справляется сам

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

Подобный компромисс заключается в том, что каждый уровень обрабатывается отдельным этапом рендеринга, поскольку эффекты применяются после него. Для небольшого количества уровней это не заметно. Если же это приводит к видимым мерцаниям в очень глубокой структуре, альтернативой является повторная подключение дочернего элемента путем изменения его key при закрытии родительского элемента, что позволяет одновременно устранить вложенное состояние. В любом случае, поскольку контент остается подключенным, сбрасываются только активные ключи; сам контент DOM никогда не уничтожается.

Уменьшение слишком большой цели для клика

Даже незначительные неудобства могут долго оставаться неразрешенными. Каждая кнопка-заголовок расположена на всей ширине строки:

className="flex w-full items-center justify-start gap-3 py-1 ..."

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

Переход с w-full на w-fit позволяет размерировать кнопку в соответствии с её содержимым: эмодзи, заголовком и символами направления.

className="flex w-fit items-center justify-start gap-3 py-1 ..."

Явный выбор w-fit вместо простого удаления w-full сделан намеренно. Кнопка <button> с параметром display: flex и автоматической шириной зависит от того, как каждый браузер внутренне размерирует элементы форм, и явное указание параметра позволяет избежать зависимости от одинакового поведения во всех браузерах.

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

Тестирование каскада состояний

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

  • Открытие L1, затем L2, затем L3 оставляет все три уровня открытыми.
  • Закрытие L1 оставляет все три уровня закрытыми.
  • Повторное открытие L1 открывает только уровень 1, при этом уровни 2 и 3 остаются закрытыми.
  • Открытие элемента-брата L1 оставляет весь его подветвь L1 закрытым.
  • Закрытие и повторное открытие всего раздела оставляет все элементы ниже него закрытыми.

Третий случай — именно тот, для которого существует функция сброса, и он наиболее склонен к ошибкам, если полагаться на код без дополнительной проверки. Также стоит добавить проверку на то, что сжатый контент по-прежнему присутствует в отрендеренном маркапе, поскольку именно от этого свойства зависит вся структура дизайна.

Заключение

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

  • Используйте функцию «отсоединения» для элементов, которые не должны существовать при закрытии, таких как модальные окна и меню; применяйте сворачивание с помощью CSS для контента, который всегда должен быть частью страницы.
  • Анимируйте высоту с помощью grid-template-rows от 0fr до 1fr, используя элемент с свойством overflow-hidden вместо определения значения max-height.
  • Сделайте свёрнутые панели inert, чтобы скрытый контент можно было индексировать, но он не мог становиться фокусом.
  • Применяйте состояние аккордеона к группам элементов-братьям с одним контекстом на уровень, а также сбрасывайте состояние дочерних элементов при закрытии родителя.
  • Подстраивайте размер интерактивных заголовков под их содержимое и тестируйте переходы между состояниями, а не определяйте их визуально.
  • Чтобы узнать о связанных методах эффективной отрисовки контента, находящегося за пределами экрана, при сохранении возможности его индексации, ознакомьтесь с методом пропуска отрисовки контента за пределами экрана с использованием свойств content-visibility и contain-intrinsic-size.