Головна / Статті / Реактивні акордіони, які можна прокручувати: згортання за допомогою CSS Grid, а не видалення компонентів

Реактивні акордіони, які можна прокручувати: згортання за допомогою 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.