Kurs JavaScript i React · Moduł 15: Wzorce i architektura
Headless Components - Oddzielenie logiki od prezentacji
W tej lekcji7
Kokpit i panel inżynierski potrzebują tej samej listy rozwijanej: otwieranie, zamykanie, wybór opcji, obsługa klawiatury i atrybuty dostępności. Kokpit chce jednak prostego przycisku, a inżynierowie kosmicznego menu z ikonami. Dwa komponenty to dwie kopie trudnej logiki i dwa miejsca na błędy. Lepszy jest uniwersalny moduł sterowania, który podłączysz do dowolnego panelu. To właśnie idea Headless Components: komponentów, które zawierają całą logikę, ale zero UI.
Czym są Headless Components?
Headless Component to komponent lub hook, który:
- Zarządza stanem i logiką (otwieranie/zamykanie, selekcja, nawigacja)
- NIE renderuje żadnego JSX, czyli nie ma własnego wyglądu
- Oddaje kontrolę nad UI konsumentowi przez propsy, render props lub hooki
Najczęściej to po prostu custom hook, jak useCrew z poprzedniej lekcji, tylko zamiast danych z API zarządza zachowaniem interfejsu. Zbudujmy headless dropdown krok po kroku. Najpierw stan listy i trzy proste akcje:
1// Headless Dropdown: cała logika, zero UI
2function useDropdown(options) {
3 const [isOpen, setIsOpen] = useState(false);
4 const [selectedIndex, setSelectedIndex] = useState(-1);
5 const [selectedOption, setSelectedOption] = useState(null);
6
7 const toggle = () => setIsOpen(prev => !prev);
8 const close = () => setIsOpen(false);
9
10 const select = (index) => {
11 setSelectedIndex(index);
12 setSelectedOption(options[index]);
13 setIsOpen(false);
14 };Nic z tego nie pojawi się jeszcze na ekranie: to czysta logika, która zadziała w każdym wyglądzie. Teraz obsługa klawiatury, czyli część najczęściej pomijana przy pisaniu dropdownu od zera:
1 // Nawigacja klawiaturą
2 const handleKeyDown = (e) => {
3 if (e.key === 'ArrowDown') {
4 e.preventDefault();
5 setSelectedIndex(prev => Math.min(prev + 1, options.length - 1));
6 } else if (e.key === 'ArrowUp') {
7 e.preventDefault();
8 setSelectedIndex(prev => Math.max(prev - 1, 0));
9 } else if (e.key === 'Enter' && isOpen && selectedIndex >= 0) {
10 e.preventDefault();
11 select(selectedIndex);
12 } else if (e.key === 'Escape') {
13 close();
14 }
15 };Strzałki przesuwają indeks w granicach listy, Enter zatwierdza wybór, a Escape zamyka listę. e.preventDefault() wyłącza domyślne reakcje przeglądarki: strzałki nie przewijają strony, a Enter nie dokłada zwykłego kliknięcia przycisku, które od razu otworzyłoby listę z powrotem. Zamkniętą listę otwiera właśnie to kliknięcie, bo na przycisku Enter i spacja działają jak klik myszą. W tej uproszczonej wersji jeden indeks służy i do podświetlenia, i do zaznaczenia, a biblioteki rozdzielają te dwie rzeczy. Na koniec hook zwraca wszystko, czego potrzebuje widok, w tym dwie funkcje zwracające gotowe zestawy propsów:
1 return {
2 isOpen, selectedIndex, selectedOption,
3 toggle, close, select, handleKeyDown,
4 getToggleProps: () => ({
5 onClick: toggle,
6 onKeyDown: handleKeyDown,
7 'aria-expanded': isOpen,
8 'aria-haspopup': 'listbox',
9 }),
10 getOptionProps: (index) => ({
11 onClick: () => select(index),
12 'aria-selected': selectedIndex === index,
13 role: 'option',
14 }),
15 };
16}getToggleProps() oddaje onClick, onKeyDown, aria-expanded i aria-haspopup, a getOptionProps(index) dodaje każdej opcji role i aria-selected. Konsument rozkłada te obiekty operatorem spread i nie musi pamiętać o dostępności. Takie funkcje nazywa się prop getters, a spotkasz je choćby w bibliotece Downshift.
Użycie headless dropdown z dowolnym UI
Klucz do headless components: ten sam hook, różne wyglądy. Pierwsza wersja to zwykły przycisk i lista, bez żadnych ozdobników:
1// Wersja 1: Prosty dropdown
2function SimpleDropdown({ options, label }) {
3 const dropdown = useDropdown(options);
4
5 return (
6 <div className="simple-dropdown">
7 <button {...dropdown.getToggleProps()}>
8 {dropdown.selectedOption || label}
9 </button>
10 {dropdown.isOpen && (
11 <ul role="listbox">
12 {options.map((opt, i) => (
13 <li key={i} {...dropdown.getOptionProps(i)}>
14 {opt}
15 </li>
16 ))}
17 </ul>
18 )}
19 </div>
20 );
21}Druga wersja korzysta z identycznego hooka, ale buduje kosmiczne menu z ikonami i strzałką, która pokazuje stan listy:
1// Wersja 2: Kosmiczny dropdown z ikonami
2function SpaceDropdown({ options, icons }) {
3 const dropdown = useDropdown(options);
4
5 return (
6 <div className="space-dropdown">
7 <button type="button" {...dropdown.getToggleProps()} className="space-trigger">
8 <span>{dropdown.selectedOption || 'Wybierz system'}</span>
9 <span>{dropdown.isOpen ? '▲' : '▼'}</span>
10 </button>
11 {dropdown.isOpen && (
12 <div className="space-menu">
13 {options.map((opt, i) => (
14 <div key={i} {...dropdown.getOptionProps(i)} className="space-option">
15 <span>{icons[i]}</span>
16 <span>{opt}</span>
17 </div>
18 ))}
19 </div>
20 )}
21 </div>
22 );
23}Oba komponenty różnią się tylko JSX-em, a hook nie zmienił się ani o linijkę. Wynik możesz też od razu rozpakować: const { isOpen, selectedOption, getToggleProps, getOptionProps } = useDropdown(options). Zwróć uwagę, że wyzwalaczem znowu jest <button>, choć wygląda zupełnie inaczej niż w pierwszej wersji. Hook dostarcza atrybuty ARIA, ale element wybierasz Ty: <div> nie dostałby fokusu z klawiatury, a nawet z tabIndex={0} i role="button" Enter i spację musiałbyś obsłużyć ręcznie.
Headless Toggle / Disclosure
Kolejny przykład to headless toggle do budowania akordeonów, rozwijanych sekcji i spoilerów. Ten hook jest krótki, bo pilnuje tylko jednej wartości logicznej:
1function useToggle(initialState = false) {
2 const [isOpen, setIsOpen] = useState(initialState);
3
4 return {
5 isOpen,
6 toggle: () => setIsOpen(p => !p),
7 open: () => setIsOpen(true),
8 close: () => setIsOpen(false),
9 getToggleProps: () => ({
10 onClick: () => setIsOpen(p => !p),
11 'aria-expanded': isOpen,
12 }),
13 getPanelProps: () => ({
14 role: 'region',
15 hidden: !isOpen,
16 }),
17 };
18}Na tym samym przełączniku zbudujemy dwa zupełnie różne komponenty. Pierwszy to akordeon, który rozwija opis systemu statku:
1// Użycie 1: akordeon
2function SystemDetails({ title, children }) {
3 const { isOpen, getToggleProps, getPanelProps } = useToggle();
4
5 return (
6 <div className="accordion-item">
7 <button {...getToggleProps()}>
8 {title} {isOpen ? '−' : '+'}
9 </button>
10 <div {...getPanelProps()}>
11 {isOpen && children}
12 </div>
13 </div>
14 );
15}Tooltip wygląda inaczej, ale korzysta z identycznego API hooka, więc logika otwierania i zamykania nie jest powielana:
1// Użycie 2: Tooltip
2function InfoTooltip({ content, children }) {
3 const { isOpen, getToggleProps, getPanelProps } = useToggle();
4
5 return (
6 <span className="tooltip-wrapper">
7 <button type="button" {...getToggleProps()} className="tooltip-trigger">
8 {children}
9 </button>
10 <span {...getPanelProps()} className="tooltip-content">
11 {isOpen && content}
12 </span>
13 </span>
14 );
15}getPanelProps() ustawia hidden i role="region": dzięki hidden zwinięty panel znika także dla czytników ekranu, a nie tylko wizualnie. Wyzwalaczem znowu jest <button>, bo aria-expanded wolno nadać tylko elementom z rolą, takim jak przycisk, a <span> nie dostałby fokusu. Ten sam kształt API znajdziesz w bibliotekach headless pod nazwą Disclosure.
Headless Tabs
Zakładki to trzeci klasyk. Hook pamięta aktywną zakładkę i nadaje role tab oraz tabpanel, które czytniki ekranu rozumieją bez dodatkowych opisów:
1function useTabs(tabCount, defaultTab = 0) {
2 const [activeTab, setActiveTab] = useState(defaultTab);
3
4 return {
5 activeTab,
6 setActiveTab,
7 getTabProps: (index) => ({
8 onClick: () => setActiveTab(index),
9 role: 'tab',
10 'aria-selected': activeTab === index,
11 tabIndex: activeTab === index ? 0 : -1,
12 }),
13 getPanelProps: (index) => ({
14 role: 'tabpanel',
15 hidden: activeTab !== index,
16 }),
17 isActive: (index) => activeTab === index,
18 };
19}tabIndex równy 0 tylko dla aktywnej zakładki to technika roving tabindex: klawisz Tab trafia od razu na aktywną zakładkę. Wzorzec zakładek z WAI-ARIA dodaje jeszcze przełączanie strzałkami, do którego przyda się nieużywany na razie parametr tabCount.
Dlaczego headless?
| Cecha | Zwykły komponent | Headless Component |
|---|---|---|
| UI | Wbudowane style/JSX | Zero, TY decydujesz |
| Reużywalność | Ograniczona do jednego wyglądu | Nieograniczona |
| Dostępność | Trzeba implementować ręcznie | Wbudowane aria-atrybuty |
| Testowalność | Wymaga renderowania | Testuj sam hook |
Headless UI w ekosystemie React
Biblioteka @headlessui/react (od twórców Tailwind CSS) dostarcza gotowe headless komponenty:
Listbox: dropdown z pełną obsługą klawiaturyCombobox: autocomplete/searchDialog: modal z focus trapDisclosure: accordion/expandableMenu: dropdown menuTabs: zakładki
Każdy z nich dostarcza logikę, aria-atrybuty i obsługę klawiatury, ale zero stylowania. Ty dostarczasz cały wygląd. Podobnie działają Radix UI Primitives oraz React Aria od Adobe.
Kiedy używać headless components?
- Budujesz design system: różne zespoły potrzebują tych samych interakcji, ale różnych stylów
- Potrzebujesz pełnej dostępności (a11y): headless komponenty mają wbudowane aria-atrybuty
- Tworzysz komponenty używane w wielu projektach: logika ta sama, style różne
- Chcesz oddzielić odpowiedzialność: hook = logika, komponent = wygląd
Headless Components działają jak uniwersalny system nawigacyjny statku: logika jest ta sama, ale każdy panel wygląda inaczej, bo każda stacja ma inne potrzeby. Moja rada: własny headless hook pisz dla prostych przełączników, takich jak useToggle. Dropdown, combobox czy dialog z pełną obsługą klawiatury i fokusu to dużo pracy, więc tam sięgnij po sprawdzoną bibliotekę. W następnej lekcji oddamy użytkownikowi komponentu jeszcze więcej kontroli dzięki Inversion of Control i zobaczymy, czym prop getters różnią się od props collection.
Pamiętaj: headless hook to silnik bez kadłuba, a kadłub projektujesz sam.
Kod do tej lekcji: App.jsx
1import React, { useState } from 'react';
2
3// === HEADLESS HOOK: useDropdown ===
4function useDropdown(options) {
5 const [isOpen, setIsOpen] = useState(false);
6 const [selectedIndex, setSelectedIndex] = useState(-1);
7 const [selectedOption, setSelectedOption] = useState(null);
8
9 const toggle = () => setIsOpen(p => !p);
10 const close = () => setIsOpen(false);
11
12 const select = (index) => {
13 setSelectedIndex(index);
14 setSelectedOption(options[index]);
15 setIsOpen(false);
16 };
17
18 // preventDefault: strzałki nie przewijają strony, a Enter nie dokłada kliknięcia, które znowu otworzyłoby listę
19 const handleKeyDown = (e) => {
20 if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex(p => Math.min(p + 1, options.length - 1)); }
21 else if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex(p => Math.max(p - 1, 0)); }
22 else if (e.key === 'Enter' && isOpen && selectedIndex >= 0) { e.preventDefault(); select(selectedIndex); }
23 else if (e.key === 'Escape') close();
24 };
25
26 return {
27 isOpen, selectedIndex, selectedOption, toggle, close, select, handleKeyDown,
28 getToggleProps: () => ({ onClick: toggle, onKeyDown: handleKeyDown, 'aria-expanded': isOpen, 'aria-haspopup': 'listbox' }),
29 getOptionProps: (i) => ({ onClick: () => select(i), 'aria-selected': selectedIndex === i, role: 'option' }),
30 };
31}
32
33// === HEADLESS HOOK: useToggle ===
34function useToggle(initial = false) {
35 const [isOpen, setIsOpen] = useState(initial);
36 return {
37 isOpen,
38 toggle: () => setIsOpen(p => !p),
39 getToggleProps: () => ({ onClick: () => setIsOpen(p => !p), 'aria-expanded': isOpen }),
40 getPanelProps: () => ({ role: 'region', hidden: !isOpen }),
41 };
42}
43
44// === KOMPONENT WIZUALNY 1: prosty dropdown ===
45function SimpleDropdown({ options, label }) {
46 const dd = useDropdown(options);
47 return (
48 <div className="dropdown">
49 <button className="dd-trigger" {...dd.getToggleProps()}>
50 {dd.selectedOption || label} <span>{dd.isOpen ? '\u25B2' : '\u25BC'}</span>
51 </button>
52 {dd.isOpen && (
53 <ul className="dd-menu" role="listbox">
54 {options.map((opt, i) => (
55 <li key={i} className={"dd-option " + (dd.selectedIndex === i ? "highlighted" : "")} {...dd.getOptionProps(i)}>
56 {opt}
57 </li>
58 ))}
59 </ul>
60 )}
61 </div>
62 );
63}
64
65// === KOMPONENT WIZUALNY 2: kosmiczny dropdown (inny wygląd, ta sama logika) ===
66function SpaceDropdown({ options, icons, label }) {
67 const dd = useDropdown(options);
68 return (
69 <div className="space-dd">
70 <button type="button" className="space-trigger" {...dd.getToggleProps()}>
71 <span>{dd.selectedOption || label}</span>
72 <span className="arrow">{dd.isOpen ? '\u25B2' : '\u25BC'}</span>
73 </button>
74 {dd.isOpen && (
75 <div className="space-menu">
76 {options.map((opt, i) => (
77 <div key={i} className={"space-option " + (dd.selectedIndex === i ? "highlighted" : "")} {...dd.getOptionProps(i)}>
78 <span className="opt-icon">{icons[i]}</span>
79 <span>{opt}</span>
80 </div>
81 ))}
82 </div>
83 )}
84 </div>
85 );
86}
87
88// === Akordeon na bazie useToggle ===
89function AccordionItem({ title, children }) {
90 const { isOpen, getToggleProps, getPanelProps } = useToggle();
91 return (
92 <div className="accordion-item">
93 <button className="accordion-header" {...getToggleProps()}>
94 {title} <span>{isOpen ? '\u2212' : '+'}</span>
95 </button>
96 <div className="accordion-body" {...getPanelProps()}>
97 {isOpen && <div className="accordion-content">{children}</div>}
98 </div>
99 </div>
100 );
101}
102
103// === GŁÓWNY KOMPONENT ===
104export default function App() {
105 return (
106 <div className="app">
107 <h1>Demo komponentów headless</h1>
108 <p className="subtitle">Te same hooki z logiką, różne wersje wyglądu</p>
109
110 <div className="section">
111 <h3>Prosty dropdown (useDropdown)</h3>
112 <SimpleDropdown options={['Mars', 'Jowisz', 'Saturn', 'Europa']} label="Wybierz planetę..." />
113 </div>
114
115 <div className="section">
116 <h3>Kosmiczny dropdown (ten sam useDropdown, inny wygląd)</h3>
117 <SpaceDropdown
118 options={['Silniki', 'Osłony', 'Nawigacja', 'Uzbrojenie']}
119 icons={['SIL', 'OSŁ', 'NAW', 'UZB']}
120 label="Wybierz system..."
121 />
122 </div>
123
124 <div className="section">
125 <h3>Akordeon (useToggle)</h3>
126 <AccordionItem title="Stan silnika">
127 <p>Napęd plazmowy pracuje z wydajnością 94%.</p>
128 </AccordionItem>
129 <AccordionItem title="Raport osłon">
130 <p>Osłony przednie: 78%. Osłony tylne: 100%.</p>
131 </AccordionItem>
132 <AccordionItem title="Nawigacja">
133 <p>Kurs na Alfa Centauri. Czas dotarcia: 4,2 roku.</p>
134 </AccordionItem>
135 </div>
136 </div>
137 );
138}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Czym są Headless Components w kontekście React?
2. Jaki jest cel metod getToggleProps() i getOptionProps() w headless hooks?
To 2 z 3 pytań do tej lekcji. Pozostałe rozwiążesz w grze.
Zadania praktyczne w grze
- Edytor kodu
Dokończ dwa headless hooki i dwa wyglądy na nich zbudowane. W useDropdown ___BLANK1___: po wybraniu opcji lista się zamyka (użyj gotowej akcji close). ___BLANK2___: getToggleProps ustawia aria-expanded zgodnie z tym, czy lista jest otwarta. ___BLANK3___: getOptionProps(index) ustawia aria-selected na true tylko dla wybranej opcji (selectedIndex), dla pozostałych false. W useToggle ___BLANK4___: getPanelProps ukrywa panel atrybutem hidden, gdy przełącznik jest zamknięty. ___BLANK5___: SimpleDropdown rozkłada na przycisku propsy zwrócone przez getToggleProps hooka useDropdown (wynik wywołania, nie samą funkcję).
- Układanie w poziomie
Ułóż składnię destrukturyzacji wyniku headless hooka useDropdown:
- Klikanie w kolejności
Kliknij w kolejności kroki tworzenia komponentu headless: