Reakcyjne akordeony nadające się do przeglądania: składanie za pomocą CSS Grid, a nie usuwania komponentów
Stwórz nawarstwione akordeony w React, które przechowują treść w DOM dla celów indeksowania, animują wysokość za pomocą wierszy siatki, ograniczają możliwość otwarcia tylko jednego elementu na poziomie oraz resetują stan po zamknięciu.
Większość poradników dotyczących akordeonów wyświetla panel tylko wtedy, gdy jest otwarty. To w porządku dla modalu, ale na stronie bogatej w treść, takiej jak portfolio, centrum dokumentacji czy FAQ, oznacza to, że tekst w każdej zamkniętej sekcji nie istnieje w dokumencie, gdy przeglądarka renderuje stronę. Ten przewodnik odbudowuje zestaw nawarstwionych sekcji, które można składać, tak aby nic nie opuszczało DOM-u, wysokość zmieniała się płynnie bez użycia „magicznych” liczb, zasada „tylko jedna otwarta sekcja” działała poprawnie na każdym poziomie nawarstwienia, ponowne otwarcie sekcji rozpoczynało ją od nowa, a cel kliknięcia znajdował się tam, gdzie kończył się tekst nagłówka.
Sytuacja: jedenaście sekcji, trzy poziomy głębokości
Rozważmy stronę osobistą z jedenaścioma sekcjami najwyższego poziomu, które można składać: o mnie, dostępność, doświadczenie, portfolio, wykształcenie, języki, umiejętności, laboratorium, pobierania, lokalizacja i kontakt. Kilka z nich zawiera dodatkowe podsekcje. Sam rozdział „Kontakt” ma trzy poziomy głębokości – profile publiczne, publikacje i tablice ofert pracy dzielą się każda na własne podgrupy.
Gdy strona jest w pełni rozwinięta, stanowi przytłaczającą ścianę tekstu. Gdy jest składana, łatwo ją przeglądać. Najoczywistszym rozwiązaniem jest umożliwienie składania sekcji. Pytanie brzmi jak to zrobić.
Wzorzec, którego uczy niemal każdy tutorial, to renderowanie warunkowe:
{isOpen && (
<div className="content">
{children}
</div>
)}
To działa, a dzięki elementowi otaczającemu można nawet dodać animacje. Jednak w ten sposób po cichu podważa cel strony, której celem jest bycie łatwo dostępnym.
Czym naprawdę jest warunkowe montowanie
{isOpen && ...} nie ukrywa niczego. Gdy isOpen ma wartość false, React nigdy nie tworzy tego poddrzewa, więc w DOM-ie nie ma nic do ukrycia ani pokazania.
Dla modali i menu rozwijanych to dokładnie takie zachowanie, jak powinno być – zamknięty dialog nie powinien znajdować się w dokumencie. W przypadku strony z treścią jest to jednak odwrotne. Na stronie portfolio sekcje składane stanowią istotę: lata doświadczenia, opisy projektów, listy technologii, obowiązki i wyniki. Każdy termin, który może szukać rekruter, znajduje się w elemencie, który początkowo jest zamknięty.
Google faktycznie uruchamia JavaScript, więc sytuacja jest mniej poważna niż kiedyś. Jednak strona jest renderowana w swoim początkowym stanie. Bot nie klikuje symboli rozwijania. Wszystko, co zostaje zdjęte podczas ładowania, nie znajduje się, ze względu na indeksowanie, na stronie.
Składanie z użyciem stylów zamiast renderowania
Rozwiązaniem jest traktowanie stanu „skurczonego” jako kwestii stylizacji, a nie decyzji dotyczącej renderowania. Treść jest montowana raz i pozostaje taką; zmienia się jedynie jej widoczna wysokość.
Tradycyjnym sposobem na animację wysokości jest użycie właściwości max-height, która zmusza do oszacowania wartości większej od najwyższej części elementu oraz akceptacji nierównomiernego tempa animacji, ponieważ przejście odbywa się w całym oszacowanym zakresie, a nie według rzeczywistej wysokości. CSS Grid oferuje prostsze podejście, pokazane tutaj za pomocą klas 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>
Dlaczego trik z grid-rows działa
Szyna siatki o rozmiarze 1fr rozszerza się, aby pomieścić naturalną wysokość swojego zawartości, natomiast szyna o rozmiarze 0fr kurczy się do zera. Przeglądarki mogą interpolować wartości pomiędzy tymi dwoma stanami, dzięki czemu przejście jest gładkie i nie wymaga ustalenia maksymalnej wartości. Wewnętrzny wrapper z atrybutem overflow-hidden jest niezbędny – bez niego zawartość wylałaby się poza wiersz o zerowej wysokości zamiast być obcięta.
Tekst markup jest teraz identyczny w stanie otwartym i zamkniętym. Różnią się jedynie obliczona wysokość i przenikliwość, więc każde słowo pozostaje w dokumencie i jest dostępne do indeksowania.
Jedna uwaga: animowanie właściwości grid-template-rows to stosunkowo nowa funkcjonalność przeglądarek. Starsze silniki po prostu przełączają się między stanami, więc jeśli musisz je obsługiwać, zaplanuj rozwiązanie awaryjne lub zaakceptuj brak animacji w tych przeglądarkach. Sprawdź aktualne dane dotyczące obsługi w kontekście swojego ruchu użytkowników.
Zapobiegaj umieszczaniu skompresowanego kontentu w kolejności kart
Zachowanie kontentu w DOM ma skutek uboczny związany z dostępnością, który należy uwzględnić. Linki i przyciski znajdujące się w wizualnie skompresowanym panelu nadal mogą otrzymywać fokus klawiatury, a czytniki ekranu mogą je nadal komunikować. Dodanie atrybutu inert do panelu, gdy jest zamknięty (lub przynajmniej aria-hidden w połączeniu z uniemożliwieniem fokusu kontrolom wewnętrznym), zapewnia, że tekst pozostanie w dokumencie dla narzędzi do przeszukiwania, jednocześnie wykluczając go z interakcji. Połącz przycisk nagłówka z atrybutem aria-expanded, aby technologie wspomagające wiedziały o stanie panelu. Przeglądarka oferuje również opcję hidden="until-found", która umożliwia przeszukiwanie kontentu za pomocą funkcji „znajdź na stronie”; warto ją rozważyć, choć jej stylizacja i animacje różnią się od podejścia opartego na siatce.
Zakres zasady „tylko jedna karta otwarta naraz”
Przy funkcji składania elementów kolejnym wymogiem jest klasyczne zachowanie akordeonu: otwarcie jednej sekcji powoduje zamknięcie pozostałych. W przypadku prostego listy wystarczy jeden stan przechowujący informację o otwartym elemencie na górze oraz sprawdzenie równości w każdym elemencie.
Sekcje nawarstwione natychmiast łamią tę zasadę. Jedna globalna reguła zamienia w momencie otwarcia potomka jego rodzica, ponieważ potomek jest po prostu kolejnym elementem, który można złożyć, a reguła nie potrafi odróżnić tych przypadków. Gdy rozszerzymy podgrupę, np. tablice ofert pracy firmy, sekcja ją zawierająca natychmiast się zamyka pod kursorem.
To ograniczenie wymaga określenia zakresu: zasada wykluczenia obowiązuje w obrębie grupy elementów będących braćmi pod tym samym rodzicem, a nie na całej stronie. Każdy element, który można złożyć, należy do grupy swojego rodzica i jednocześnie tworzy nową grupę dla swoich potomków. Grupa to mała struktura przechowująca informację o stanie otwarcia oraz mechanizm jej ustawiania:
type CollapseGroup = {
openKey: string | null;
setOpenKey: (key: string | null) => void;
};
Ta forma jest udostępniana za pośrednictwem kontekstu React, przy czym null oznacza „nie znajduje się w grupie”:
const CollapseGroupContext = createContext<CollapseGroup | null>(null);
Każdy węzeł odczytuje kontekst swojego rodzica, aby ustalić, czy jest otwarty, a następnie otacza swoje dzieci nowym dostawcą. Przy trzech poziomach wnestowania stan znajduje się w trzech niezależnych kontekstach, zamiast w jednej płaskiej strukturze z kluczami złożonymi, takimi jak contact/profiles/boards. Dzięki temu każdy poziom pozostaje prosty, a głębokość jest nieograniczona bez konieczności dodatkowego zarządzania.
Resetywanie stanu wnestowanego po zamknięciu rodzica
Bardziej subtelny problem użyteczności pojawia się dopiero po pewnym czasie korzystania z strony. Otwórz sekcję, potem podsekcję, a następnie podpodsekcję. Zamknij sekcję najwyższego poziomu i przeczytaj coś innego. Gdy później ponownie otworzysz tę górną sekcję, wróci ona dokładnie do stanu trzech poziomów, który zostawiłeś.
Zachowanie stanu może wydawać się troskliwe, ale jest mylące. Ponowne otwarcie czegoś oznacza rozpoczęcie od nowa, a interfejs jest sprzeczny z tą oczekiwaną zachowaniem. Ty zapomniałeś, gdzie byłeś; interfejs nie.
Rozwiązaniem jest takie ustawienie, aby zamknięcie węzła usunęło wszystko znajdujące się poniżej niego. Dostawca posiada klucz otwarcia swojej grupy:
const CollapseGroupProvider = ({ isOpen, children }) => {
const [openKey, setOpenKey] = useState<string | null>(null);
a efekt usuwa ten klucz za każdym razem, gdy zamknięty zostanie własny węzeł dostawcy:
useEffect(() => {
if (!isOpen) setOpenKey(null);
}, [isOpen]); // ...
};
Dlaczego kaskada sama się zarządza
Gdy zamknięty zostanie węzeł pierwszego poziomu, jego dostawca resetuje wybór drugiego poziomu na null. Wszystkie węzły drugiego poziomu stają się wtedy zamknięte, co uruchamia efekty ich dostawców, które z kolei usuwają dane z trzeciego poziomu i tak dalej. Reset rozprzestrzenia się na każdą głębokość bez konieczności ręcznego przeszukiwania drzewa.
Kompromis polega na tym, że każdy poziom jest renderowany w oddzielnej fazie, ponieważ efekty są aplikowane po zakończeniu renderowania. W przypadku niewielu poziomów jest to niewyczuwalne. Jeśli zauważysz, że powoduje to widoczne migotanie w bardzo głębokiej strukturze, alternatywą jest ponowne podłączenie dostawcy potomnego poprzez zmianę jego key po zamknięciu elementu nadrzędnego, co usunie stan wewnętrzny za jednym razem. W obu przypadkach, ponieważ treść pozostaje podłączona, resetują się tylko otwarte klucze; sam content DOM nigdy nie jest niszczony.
Zmniejszenie zbyt dużego celu kliknięcia
Mniejszy problem może długo wymagać diagnozy. Każdy przycisk nagłówkowy rozciągnięty na całym wierszu:
className="flex w-full items-center justify-start gap-3 py-1 ..."
W rezultacie cała linia reaguje na kliknięcia, włącznie z pustym obszarem po tytule, który rozciąga się niemal na cały ekran. Próba wybrania tekstu lub kliknięcie w margines może niespodziewanie złożyć lub rozłożyć dany fragment.
Zmiana z w-full na w-fit dostosowuje rozmiar przycisku do jego zawartości: emotikonki, tytułu oraz symbolu strzałki.
className="flex w-fit items-center justify-start gap-3 py-1 ..."
Wybór w-fit w sposób celowy, zamiast po prostu usunięcia w-full, ma swoje uzasadnienie. Przycisk <button> z ustawieniem display: flex i automatyczną szerokością zależy od sposobu, w jaki każdy przeglądarka domyślnie dostosowuje rozmiar elementów formularza, a wybór tego rozwiązania pozwala uniknąć polegania na tym, że zachowanie będzie identyczne we wszystkich przeglądarkach.
Na małych ekranach również nie występuje żadna regresja. Szerokość typu fit-content jest ograniczona dostępną przestrzenią i nigdy nie przekracza rozmiaru kontenera, więc długi tytuł na telefonie nadal się zwija tak samo jak wcześniej.
Testowanie łańcucha stanów
Logika oparta na stanach nawzajem powiązanych poprzez efekty wydaje się słuszna podczas przeglądania, ale zawodzi w praktyce. Przed wysłaniem produktu warto wyrenderować interfejs za pomocą React w jsdom i sprawdzić kluczowe przypadki:
- Otwarcie poziomu L1, następnie L2 i potem L3 powoduje pozostanie wszystkich trzech otwartych.
- Zamknięcie L1 powoduje zamknięcie wszystkich trzech poziomów.
- Ponowne otwarcie L1 otwiera tylko poziom 1, przy czym poziomy 2 i 3 pozostają zamknięte.
- Otwarcie elementu pokrewnego L1 powoduje zamknięcie całej gałęzi L1.
- Zamknięcie i ponowne otwarcie całej sekcji powoduje zamknięcie wszystkiego poniżej niej.
Trzeci przypadek jest tym, dla którego istnieje funkcja służąca do przywrócenia stanu, i jest najprawdopodobniej tym, który pójdzie nie tak, jeśli zaufamy kodowi bez dokładnej weryfikacji. Warto również dodać sprawdzenie, czy skompresowana treść nadal jest obecna w wyrenderowanym kodzie, ponieważ to właśnie od tej cechy zależy cały projekt.
Podsumowanie
Żadna z tych opcji nie pojawia się na skrинie ekranu. Odwiedzający nie zauważą, że skompresowany tekst nadal znajduje się w DOM-ie, że ponowne otwarcie bloku oznacza czystą kartkę, ani że nagłówek przestaje reagować na kliknięcia w miejscu, gdzie kończy się tekst. Gdy jest to zrobione dobrze, jedyne wrażenie to brak czegokolwiek irytującego, a wyszukiwarki widzą całą stronę.
- Wyłączaj elementy, które nie powinny istnieć po zamknięciu, takie jak okna modalne i menu; używaj CSS do składania treści, które zawsze powinny być częścią strony.
- Animuj wysokość za pomocą
grid-template-rowsod0frdo1froraz ustawoverflow-hiddenna potomku, zamiast zgadywać wartośćmax-height. - Uczyń skompresowane panele
inert, aby ukryta treść była indeksowalna, ale nie dostępna do skupienia uwagi.
Aby zapoznać się z powiązanymi technikami utrzymywania niskich kosztów renderowania treści poza ekranem przy jednoczesnym umożliwieniu ich indeksowania, zobacz skipping offscreen rendering with content-visibility.