Галоўная / Артыкулы / Рэакт-аккордыяны, якія можна прасуваць: складванне за дапамою CSS Grid, а не выключэнне

Рэакт-аккордыяны, якія можна прасуваць: складванне за дапамою CSS Grid, а не выключэнне

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

1798 слоў

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

Сценарый: адзенацца разделаў, трохі роўней глыбіны

Разглянем персональныя сайты з аўтаматычна складваемымі адзеўнадцума верхнімі разделамі: пра мене, доступнасць, аднаванне навыкаў, портфолія, выхаванне, мовы, навыкі, лабараторыя, завантажэння, месца розташоўкі і кантакты. Некалькі з іх ёсць ўнутранімі. Лякш за ўсе, раздел «Кантакты» мае тры роўні, пры чым публічныя профілі, публікацыі і борды вакансый кожны з’яўляецца як самастоятельная падгрупа.

Калі всі разделы раскрываюцца, стораніца ператвараецца на вялічызненую стэну тексту. Калі ж яны складваюцца, ўсё легка прасканаваць. Таму явным выборам ёсць можласць складвання разделаў. Пытанне — як гэта зрабіць.

Модель, якую практычна кожны туторыял выучвае, — это умовная візуалізацыя:

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

Гэта працюе, і за дапамогою елемента-обгортка можна нават стварыць анімацыю. Але гэта таямна пасупільвае мету стораніцы, якая існуе для таго, каб ёю можна было знайсці.

Што на самае працоўвае умовнае адображэнне

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

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

Google адбывае JavaScript, таму гэта менш серьёзна, чым раней. Але сторанка адрасаваецца ў своем пачатковым стане. Краўлер не клацае на знакі. Усё, што адключаецца пад час завантажэння, з метай індексавання не ўключаецца ў сторанку.

Складзэнне з выкарыстоўванням стыляў узамен адрасавання

Рашэнь — спрытваць „collapsed“ як пытанне стайлізацыі, а не як рашэнь пра адражэнне. Контэнт падключаецца раз і застаецца падключаным; зменшыцца толькі яго видны высота.

Традыцыйны спосаб прыменения анімацыі высоты — 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.