Десь у вашому продакшн-каталозі є сторінка, чий «простий» FAQ-акордеон підтягує 180КБ JavaScript, блокує рендер і все одно неправильно працює з клавіатурною навігацією. Відчуваєте це: терпіння користувача тане, показники Lighthouse скаржаться, а у вас незабаром з’явиться інцидент із заголовком «Акордеон не відкривається на iPad».
Є кращий шлях: відправте семантичний HTML як базу, а потім лише підсилюйте. Використовуйте <details>/<summary>, де це підходить, і пишіть JavaScript лише там, де він справді потрібен.
Підхід: поступове покращення, а не поступальне жалкування
Коли ви робите UI-компоненти для вебу, ви відправляєте не тільки пікселі. Ви відправляєте моделі відмов: як сторінка поводиться, коли JavaScript підвантажується пізно, коли CSS не завантажується, коли користувач навігує клавіатурою, коли браузер застарілий, коли корпоративний проксі підмішує щось, або коли ваш власний збирач тихо подвоює payload, бо хтось імпортував утиліту з «спільного UI-пакета».
Поступове покращення — це нудна дисципліна забезпечення робочої базової взаємодії з мінімальними припущеннями. Потім ви додаєте можливості, не заради моди, а заради користі. Базовий рівень має бути читаємим, навігабельним і керованим на максимально примітивному стеку: сервер-рендерений HTML, мінімальний CSS і нуль залежностей JavaScript для ключової взаємодії там, де це можливо.
Думка: Якщо ваш акордеон вимагає фреймворку JavaScript, щоби відкриватися, це не акордеон. Це вивіска: «Я — машина станів без запасного плану».
Нативні <details>/<summary> — найчистіша історія поступового покращення для дисклейзерів і акордеонів. Вкладки складніші: немає нативного елементу «tab», і спроби примусити <details> під таб-семантику дають UI, що виглядає як вкладки, але читається як набір перемикачів.
Дві цілі визначають усе тут:
- Стійкість: якщо скрипти впадуть, контент усе одно буде доступний і взаємодія матиме сенс.
- Спостережуваність: якщо щось ламається, ви швидко діагностуєте це звичайними інструментами, а не шукаєте жреця й мініфікований бандл.
І так — ви все ще можете мати приємну анімацію, deep-linking і поведінку «лише одна відкрита панель». Просто заробіть це поступово.
Цитата на дорогу, бо це досі правда в світі UI: «Надія — це не план.» — Вінс Ломбарді
Жарт №1: UI-бібліотека — як кімнатна рослина: здається безпечною, допоки не усвідомиш, що вона потребує постійної уваги й якимось чином притягує баги.
Факти та історичний контекст для аргументів
Це не тривіальні знання заради самого факту. Це ті факти, що допоможуть виграти дизайн-рев’ю й запобігти командному рішенню тягнути важку бібліотеку «бо всі так роблять».
<details>— це справжній HTML-елемент. Це не div з атмосферою; це стандартизований віджет-дисклозер з вбудованим перемиканням і визначеним відображенням для доступності в сучасних браузерах.- Раніше поведінка браузерів була непослідовною. Протягом років деякі рушії по-різному обробляли фокус/клавіатуру для
<summary>; багато команд писали поліфіли. Сьогодні це переважно стабільно, але старі припущення залишаються в коді. - Патерн «акордеон» старший за веб-застосунки. Десктопні інтерфейси мали трикутники-дисклозери і розгортувані панелі задовго до SPA; веб підхоплює ту саму ментальну модель, тепер із семантикою.
- Вкладки й акордеони виконують різні задачі. Вкладки означають один «поточний» вигляд серед однолітків; акордеони — множинні розгортувані дисклозери. Користувачі інтерпретують їх по-різному, і скрінрідери оголошують їх по-різному.
- Поступове покращення передує сучасним фреймворкам. Воно виросло з реальності, що веб неоднорідний: повільні мережі, часткова підтримка та збої — це норма, а не крайні випадки.
- ARIA — не заміна семантиці. ARIA може описати поведінку, але якщо можна використати нативний елемент, зазвичай так і слід робити. ARIA — гострий інструмент; він може і допомогти, і нашкодити.
- Вкладки у фреймворках часто ламаються при гідратації. Сервер рендерить «Вкладка A», клієнт гідрує «Вкладка B», і ви отримуєте мерехтіння плюс розбіжність станів. Нативний дисклозер уникає багатьох таких проблем, бо браузер контролює базовий стан.
- Deep-linking — постійна вимога. Команди продукту люблять «поділитися посиланням на третю панель». Якщо ігнорувати це, хтось допише логіку hash пізніше й принесе нові баги.
Акордеони через details/summary: вибір за замовчуванням
Використовуйте <details>, коли у вас є заголовок, що переключає видимість контенту. Все. Якщо взаємодія — «клік по заголовку показує/ховає тіло», не сперечайтеся з собою: починайте з <details>.
Базовий HTML, що працює там, де працює контент
Ця базова версія не залежить від CSS чи JavaScript. Якщо стилі не завантажаться, контент залишається лінійним та читабельним. Якщо скрипти впадуть, дисклозер усе одно відкривається й закривається.
cr0x@server:~$ cat accordion.html
<section aria-label="Shipping FAQs">
<h2>Shipping</h2>
<details>
<summary>When do you ship?</summary>
<p>Orders placed before 2pm ship the same business day.</p>
</details>
<details>
<summary>Do you ship internationally?</summary>
<p>Yes. Duties and taxes are calculated at checkout when available.</p>
</details>
<details open>
<summary>How do returns work?</summary>
<p>Start a return within 30 days. We email a label if eligible.</p>
</details>
</section>
Зверніть увагу на атрибут open. Це не просто декорація: це ваш сервер-рендерений стан за замовчуванням, і це зручний спосіб зробити «перший елемент відкритим за замовчуванням» без скриптів.
CSS для details/summary без руйнування поведінки
Швидкий спосіб зіпсувати <details> — видалити маркер у <summary>, прибрати outline фокусу і замінити його div-ом через «дизайн». Не робіть так. Ви можете стилювати елемент і зберегти його працездатність.
- Збережіть видимий індикатор фокусу на
summary. - Якщо змінюєте маркер, робіть це в CSS, а не через втрату семантики.
- Не ставте інтерактивні контролери всередину
summary, якщо ретельно не протестували клавіатурну поведінку (спойлер: може стати дивно).
Поведінка «лише одна відкрита панель» (опціонально)
Нативний <details> не примушує «лише одна відкрита» правило. Це добре: елемент не нав’язує UX. Якщо дизайнери наполягають на класичній поведінці акордеона (відкриття одного закриває інші), підсиліть це невеликим скриптом, що слухає подію toggle.
Ключовий принцип: якщо скрипт не спрацює, користувачі все ще зможуть відкривати декілька панелей. Це прийнятна резервна поведінка.
cr0x@server:~$ cat accordion-one-open.js
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll("[data-accordion]").forEach((root) => {
const items = Array.from(root.querySelectorAll("details"));
root.addEventListener("toggle", (e) => {
const target = e.target;
if (!(target instanceof HTMLDetailsElement)) return;
if (!target.open) return;
for (const item of items) {
if (item !== target) item.open = false;
}
});
});
});
Підключіть його ось так:
cr0x@server:~$ cat accordion-enhanced.html
<section data-accordion aria-label="Billing FAQs">
<h2>Billing</h2>
<details>
<summary>Can I get an invoice?</summary>
<p>Invoices are available in your account within 24 hours.</p>
</details>
<details>
<summary>Do you support ACH?</summary>
<p>Yes for annual plans; contact support to enable it.</p>
</details>
</section>
Це правильне поступове покращення: базова поведінка нативна; розширення додаткове; режим відмови прийнятний.
Покращення, які не підривають стійкість
Покращення — це місце, де команди ненароком перебудовують UI-бібліотеку й потім дивуються, чому вона поводиться як така. Якщо ви підсилюєте <details>, зберігайте форму DOM і нативну взаємодію. Не боріться з елементом.
Покращення 1: анімація відкриття/закриття без лагів
Анімація висоти — класична пастка. Вона викликає ре-лейаут, може лагати під навантаженням і часто ламається при динамічному контенті. Якщо мусите анімувати, віддавайте перевагу анімації непрозорості та невеликого трансформу, або використовуйте нові CSS-підходи з content-visibility для великих панелей. Тримайте все лаконічно.
Також: не анімуйте так, щоб це відкладало контент для допоміжних технологій. Анімація — прикраса; доступність — основа.
Покращення 2: deep-link на конкретну панель
Коли хтось ділиться «поглянь третє питання», йому потрібен стабільний URL, що відкриває потрібну панель. Зробіть це через id та фрагментну логіку.
- Дайте кожному
<details>стабільнийid. - При завантаженні, якщо
location.hashзбігається зiddetails, відкрийте його й прокрутіть у видимість.
cr0x@server:~$ cat accordion-deeplink.js
document.addEventListener("DOMContentLoaded", () => {
const id = decodeURIComponent(location.hash.replace(/^#/, ""));
if (!id) return;
const el = document.getElementById(id);
if (el && el.tagName === "DETAILS") {
el.open = true;
el.scrollIntoView({ block: "start" });
el.querySelector("summary")?.focus();
}
});
Режим відмови, якщо скрипт не завантажився: сторінка все одно працює; хеш просто не відкриє панель автоматично. Це прийнятно.
Покращення 3: аналітика без перетворення UI на телеметричний рушій
Від вас попросять логувати, які панелі відкривають користувачі. Ок. Використовуйте нативну подію toggle; не вішайте click-обробники на summary, що перезаписують стандартну поведінку. Ваше завдання — спостерігати, а не захоплювати кермо.
cr0x@server:~$ cat accordion-analytics.js
document.addEventListener("DOMContentLoaded", () => {
document.body.addEventListener("toggle", (e) => {
const d = e.target;
if (!(d instanceof HTMLDetailsElement)) return;
if (!d.id) return;
const payload = { id: d.id, open: d.open, ts: Date.now() };
navigator.sendBeacon?.("/ui/toggle", JSON.stringify(payload));
}, true);
});
Якщо sendBeacon недоступний, нічого не ламається. Ви втрачаєте аналітику, а не UX.
Покращення 4: друк та «відсутність CSS»
Стилі для друку важать більше, ніж здається, бо «друк» часто означає «зберегти як PDF», а «зберегти як PDF» часто означає «прикріпити до тикета на відповідність». Переконайтеся, що відкриті панелі друкуються розгорнутими, або виберіть правило «друкувати все розгорнутим».
Практичний патерн: у CSS для друку примусово відкривати всі details.
Вкладки: коли details не підходить і як зробити правильно
Вкладки — не акордеони. Вони — віджет з одиничним вибором: одна вкладка активна, і її панель — поточний вигляд. Це важливо для допоміжних технологій, клавіатурних конвенцій і очікувань користувачів. Примушувати <details> під поведінку вкладок дає мульти-відкритий віджет, що виглядає як вкладки, але поводиться як стос перемикачів.
Отже, який базис для вкладок, якщо ми не використовуємо бібліотеку?
Базовий рівень: звичайний список посилань
Найстійкіший табовий базис —… не вкладки. Це набір посилань на розділи сторінки (або на окремі сторінки). Завантажується швидко, працює всюди і тривіально підтримує deep-linking.
cr0x@server:~$ cat tabs-baseline.html
<nav aria-label="Account sections">
<ul>
<li><a href="#profile">Profile</a></li>
<li><a href="#security">Security</a></li>
<li><a href="#billing">Billing</a></li>
</ul>
</nav>
<section id="profile">
<h2>Profile</h2>
<p>Update your name and contact details.</p>
</section>
<section id="security">
<h2>Security</h2>
<p>Manage sessions and multi-factor authentication.</p>
</section>
<section id="billing">
<h2>Billing</h2>
<p>Invoices, payment methods, and plan changes.</p>
</section>
Це варіант «працює в текстовому браузері». І так, у корпоративній реальності іноді саме це й рятує: зламаний бандл не повинен заважати клієнту знайти «Скинути MFA».
Поступове покращення у справжні вкладки (ARIA + мінімальний JS)
Коли вкладки справді потрібні — бо контент рівноцінний і інтерфейс щільний — підсилюйте від бази. Зберігайте ті самі секції з id. Додайте tablist, побудований на тих посиланнях. Потім JS ховає/показує панелі. Якщо JS не працює, користувач усе ще має посилання і секції.
Profile content. In your real app, this would be real forms, not vibes.
Security content.
Billing content.
І скрипт:
cr0x@server:~$ cat tabs.js
function activateTab(tab, tabs, panels, { focus = true } = {}) {
for (const t of tabs) t.setAttribute("aria-selected", String(t === tab));
for (const p of panels) p.hidden = (p.id !== tab.getAttribute("aria-controls"));
if (focus) tab.focus();
}
document.addEventListener("DOMContentLoaded", () => {
document.querySelectorAll('[role="tablist"]').forEach((tablist) => {
const tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
const panels = tabs
.map(t => document.getElementById(t.getAttribute("aria-controls")))
.filter(Boolean);
tablist.addEventListener("click", (e) => {
const tab = e.target.closest('[role="tab"]');
if (!tab) return;
activateTab(tab, tabs, panels);
history.replaceState(null, "", "#" + tab.id);
});
tablist.addEventListener("keydown", (e) => {
const current = document.activeElement.closest?.('[role="tab"]');
if (!current) return;
const i = tabs.indexOf(current);
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
e.preventDefault();
activateTab(tabs[(i + 1) % tabs.length], tabs, panels);
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
e.preventDefault();
activateTab(tabs[(i - 1 + tabs.length) % tabs.length], tabs, panels);
} else if (e.key === "Home") {
e.preventDefault();
activateTab(tabs[0], tabs, panels);
} else if (e.key === "End") {
e.preventDefault();
activateTab(tabs[tabs.length - 1], tabs, panels);
}
});
// Deep-link: open tab by #tab-id
const hash = decodeURIComponent(location.hash.replace(/^#/, ""));
if (hash) {
const t = document.getElementById(hash);
if (t && tabs.includes(t)) activateTab(t, tabs, panels, { focus: false });
}
});
});
Це ядро: стан вибору, приховані панелі, клавіатурна навігація і deep-linking. Жодного фреймворку. Жодної системи реактивності. Жодного «хранилища вкладок».
Жарт №2: Якщо ви будуєте вкладки за допомогою глобальної шини подій, вітаю — ви винайшли тостер, що потребує Kubernetes.
Чому не вкладки тільки на CSS?
CSS-only вкладки з radio-кнопками можуть працювати, але часто крихкі і незручні для deep-linking, інтеграції з історією і послідовності в допоміжних технологіях. Використовуйте їх для маленьких маркетингових віджетів, якщо треба. Для продуктового UI ~40 рядків скрипта зазвичай більш надійні й значно легше дебажаться.
Доступність: що гарантувати, що тестувати
Доступність — це не «додай ARIA і все». Це гарантування, що користувачі можуть керувати інтерфейсом з клавіатури й допоміжних технологій, і що оголошення відповідають реальним змінам.
Для details/summary
- Підтримка клавіатури:
summaryмає бути фокусованим і перемикатися з клавіатури. Сучасні браузери це роблять, якщо ви не зламаєте поведінку стилями чи обробниками подій. - Видимий фокус: ніколи не видаляйте outline фокусу без чіткої заміни.
- Клікабельна зона: залишайте
summaryосновною цільовою зоною. - Вкладені інтерактивні контролі: уникайте кнопок/посилань всередині
summary; якщо неможливо — ретельно тестуйте, бо кліки й toggle можуть конфліктувати.
Для вкладок
- Ролі й зв’язки:
tablistмістить елементиtab; кожна вкладка маєaria-controlsнаtabpanel; кожна панель маєaria-labelledbyна вкладку. - Стан вибору: лише одна вкладка повинна мати
aria-selected="true". - Клавіатурні конвенції: стрілки переміщують між вкладками; Home/End переходять; Enter/Space або активують (ручний режим), або ні (автоматичний) — оберіть один підхід і дотримуйтеся його.
- Приховані панелі: використовуйте атрибут
hidden, щоб приховані панелі не були в дереві доступності.
Думка: Не призначайте роль tab посиланню, якщо ви навмисно не змінюєте поведінку посилання. Вкладка — це не навігаційне посилання; трактуйте її як контрол.
Що тестувати на практиці
Тестування доступності менш містичне, ніж здається. Ви перевіряєте передбачувану механіку:
- Клавіша Tab переміщує фокус до summary/tab в логічному порядку.
- Enter/Space перемикає
<details>відкриття/закриття. - Стрілки переміщують фокус між вкладками; фокус не губиться в прихованому контенті.
- Скрінрідер оголошує тип керування й стан (expanded/collapsed; selected tab).
Практичні завдання: команди, результати та рішення
Це задачі, які ви виконуєте, коли «простий акордеон» стає проблемою в продакшні. Кожна включає реалістичну команду, що повертає результат, та рішення, яке ви ухвалюєте далі. Тут фронтенд зустрічається з SRE: ви не гадаєте — ви вимірюєте.
Завдання 1: Перевірити, чи HTML справді містить details/summary (немає лише клієнт-рендерингу)
cr0x@server:~$ curl -sS https://app.example.internal/faq | grep -n "<details" | head
142:<details id="returns">
163:<details id="shipping">
Значення: У відповіді сервера вже є віджети-дисклозери. Добрий базовий рівень. Рішення: Можна покладатися на поступове покращення; відмова JS не залишить FAQ пустим.
Завдання 2: Виявити, чи підтягується UI-бібліотека лише для акордеону/вкладок
cr0x@server:~$ curl -sS https://app.example.internal/faq | grep -Eo 'src="[^"]+\.js"' | head
src="/assets/runtime-8a1c.js"
src="/assets/vendor-2b19.js"
src="/assets/faq-41d0.js"
Значення: На сторінці є vendor-бандл. Рішення: Проведіть аудит вмісту; якщо це переважно рантайм UI-фреймворку, розгляньте варіант відправити простий HTML для цієї сторінки.
Завдання 3: Кількісно оцінити JS-пейлоуд і заголовки кешування
cr0x@server:~$ curl -sSI https://app.example.internal/assets/vendor-2b19.js | sed -n '1,12p'
HTTP/2 200
content-type: application/javascript
cache-control: public, max-age=31536000, immutable
content-length: 286401
etag: "vendor-2b19"
Значення: ~280КБ vendor JS, довгостроково кешується. Не катастрофа, але багато для «FAQ-акордеона». Рішення: Якщо сторінка — високонавантажена або потрібна під час інциденту, видаліть непотрібний JS і залиште її статичною.
Завдання 4: Виявити, чи кастомний JS перевизначає нативну поведінку акордеона
cr0x@server:~$ rg -n "preventDefault\\(\\)" public/assets/faq-41d0.js | head
1187:e.preventDefault();
Значення: Скрипт виконує preventDefault на чомусь — ймовірно, на кліку по summary. Рішення: Перегляньте обробник; видаліть preventDefault, якщо він не потрібен.
Завдання 5: Підтвердити, що сторінка працює з вимкненим JavaScript (headless-перевірка)
cr0x@server:~$ chromium --headless --disable-gpu --dump-dom https://app.example.internal/faq | grep -n "<summary" | head
143:<summary>How do returns work?</summary>
164:<summary>Do you ship internationally?</summary>
Значення: Summary присутні у дампі DOM. Рішення: Якщо взаємодія ламається тільки коли JS увімкнено, проблема в коді покращення, а не в базі.
Завдання 6: Перевірити дубльовані id, що ламають deep-linking
cr0x@server:~$ chromium --headless --disable-gpu --dump-dom https://app.example.internal/faq | grep -o 'id="[^"]*"' | sort | uniq -d | head
id="shipping"
Значення: Існує дубль id="shipping". Рішення: Виправте шаблони, щоби гарантувати унікальні ID; deep-linking і зв’язки міток від цього залежить.
Завдання 7: Виявити thrash макета в enhancement-скрипті (підказка профілювання)
cr0x@server:~$ rg -n "getBoundingClientRect\\(|offsetHeight|scrollHeight" tabs.js accordion-one-open.js
Значення: У цих невеликих скриптах немає прямих читань layout. Рішення: Якщо ви знайдете такі виклики в циклах або в обробниках toggle — очікуйте лагів; перепишіть логіку анімації.
Завдання 8: Підтвердити, що CSP не блокує ваші невеликі скрипти-покращення
cr0x@server:~$ curl -sSI https://app.example.internal/faq | grep -i content-security-policy
content-security-policy: default-src 'self'; script-src 'self'; object-src 'none'
Значення: Inline-скрипти ймовірно блокуються; зовнішні скрипти з ‘self’ дозволені. Рішення: Розміщуйте покращення як статичні файли, а не як inline <script>.
Завдання 9: Переконатися, що таб-панелі приховані від дерева доступності
cr0x@server:~$ chromium --headless --disable-gpu --dump-dom https://app.example.internal/account | grep -n 'role="tabpanel"' | head
88:<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
92:<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>
Значення: Неактивні панелі мають hidden. Рішення: Продовжуйте використовувати hidden замість приховання тільки CSS для таб-панелей.
Завдання 10: Знайти обробники подій, що можуть множитись (подвійне підключення при ререндері)
cr0x@server:~$ rg -n "addEventListener\\(\"click\"|addEventListener\\(\"toggle\"" public/assets/*.js | head
public/assets/faq-41d0.js:221:addEventListener("click", function(e){
public/assets/faq-41d0.js:489:addEventListener("click", function(e){
Значення: У бандлі є кілька click-слухачів. Це не обов’язково погано, але підозріло для простої сторінки. Рішення: Переконайтеся, що слухачі делегуються один раз на контейнер, а не підключаються для кожного елемента при кожному ререндері.
Завдання 11: Виміряти TTFB і час завантаження контенту, щоб відокремити мережеві проблеми від JS
cr0x@server:~$ curl -o /dev/null -sS -w 'ttfb=%{time_starttransfer} total=%{time_total} size=%{size_download}\n' https://app.example.internal/faq
ttfb=0.084531 total=0.129774 size=40218
Значення: Сервер швидкий; мережа — не проблема. Рішення: Якщо UX повільний, концентруйтеся на render-blocking ресурсах і JavaScript на головному потоці.
Завдання 12: Перевірити, чи enhancement-скрипт блокує рендер
cr0x@server:~$ curl -sS https://app.example.internal/faq | grep -n "<script" | head
35:<script src="/assets/vendor-2b19.js"></script>
36:<script src="/assets/faq-41d0.js"></script>
Значення: Скрипти підключені без defer чи type="module". Вони блокують парсинг. Рішення: Додайте defer до некритичних скриптів, особливо тих, що відповідають тільки за покращення.
Завдання 13: Перевірити gzip/brotli-компресію для JS-пейлоуду
cr0x@server:~$ curl -sSI -H 'Accept-Encoding: br' https://app.example.internal/assets/vendor-2b19.js | grep -iE 'content-encoding|content-length'
content-encoding: br
content-length: 68421
Значення: Brotli значно зменшує розмір передачі. Рішення: Якщо компресії немає, виправте конфіг CDN/сервера перед тим, як сперечатися про мікрооптимізації.
Завдання 14: Переконатися, що «одна відкрита» поведінка не нав’язана хаком у CSS
cr0x@server:~$ rg -n "details\\[open\\].*~.*details\\[open\\]" public/assets/*.css | head
Значення: CSS-сусідський хак не знайдено. Рішення: Використовуйте невеликий JS-підхід; CSS не може надійно гарантувати «лише одна відкрита» у довільному DOM-розташуванні.
Швидкий план діагностики
Коли вкладки чи акордеони «іноді не працюють», найгірший рух — спочатку дивитися на код. Діагностуйте як оператор: звузьте клас помилок за хвилини.
Перше: чи базовий HTML правильний?
- Перевірка: Перегляньте вихідний код сторінки (view source), а не інспектор, і підтвердіть наявність
<details>/<summary>або того, що контент вкладок існує як звичайні секції. - Якщо відсутні: Ви робите клієнт-тільки рендеринг. Вирішіть, чи це прийнятно для цього компонента. Для FAQ і допомоги зазвичай ні.
Друге: чи JavaScript випадково блокує взаємодію?
- Перевірка: Шукайте
preventDefault()на кліках summary, глобальні обробники кліків або оверлеї, що перехоплюють кліки. - Якщо знайдено: Видаліть або обмежте обробник. Нативний
<details>не має потреби в додатковій клямці для кліків.
Третє: чи проблема у стані, гідратації чи дублюванні?
- Перевірка: Дубльовані ID, множинне підключення обробників і розбіжність сервер/клієнт щодо початкового відкритого/вибраного стану.
- Якщо розбіжність: Нехай сервер виводить канонічний початковий стан (наприклад, додаючи атрибут
open; вибираючи першу вкладку), і нехай клієнт підсилює без переписування історії.
Четверте: вузьке місце — продуктивність чи коректність?
- Перевірка: Взаємодії відкладені (головний потік зайнятий) або зламані (немає перемикання)?
- Якщо відкладені: Профілюйте довгі завдання; видаліть важкий бібліотечний код зі сторінок з дисклозерами; додайте defer скриптам.
- Якщо зламані: Спершу зосередьтеся на обробці подій і структурі DOM; оптимізація продуктивності не виправить некоректну семантику.
Типові помилки: симптом → корінь → виправлення
Цей розділ існує тому, що ці баги повторюються в командах як сезонний грип, тільки лікування — дисципліна.
1) «Акордеон не відкривається на деяких пристроях»
Симптом: Клік по summary нічого не робить або відкриття відбувається, а потім відразу закривається.
Корінь: Клік-хендлер на summary викликає preventDefault() або перемикає open двічі (одноразово браузером, одноразово скриптом). Часто привноситься аналітикою або «кастомною анімацією».
Виправлення: Прибрати preventDefault; слухати toggle на details. Якщо потрібен повний контроль, припиніть покладатися на нативний toggle і прийміть, що ви будуєте кастомний компонент (і тоді тестуйте як такий).
2) «Користувачі з клавіатурою не можуть керувати акордеоном»
Симптом: Клавіша Tab пропускає summaries або фокус невидимий.
Корінь: CSS видалив outline; відображення summary змінене так, що фокус ламається; summary замінено на div.
Виправлення: Залишайте справжній <summary>. Поверніть стилі фокусу. Тестуйте клавіатурою перед злиттям.
3) «Deep links відкривають не ту панель»
Симптом: Фрагмент URL вказує на панель, але відкривається інша, або прокрутка стрибає неправильно.
Корінь: Дубльовані ID або скрипти, що перезаписують location.hash на завантаженні без перевірки цілі.
Виправлення: Гарантуйте унікальні ID; викликайте history.replaceState лише при явній дії користувача; перевіряйте ціль хеша.
4) «Вкладки мерехтять при завантаженні»
Симптом: Усі панелі коротко видно, потім одна ховається; або активна вкладка змінюється через мить.
Корінь: Базовий рендер показує всі секції; enhancement-скрипт ховає їх після layout; розбіжність гідратації або пізні скрипти.
Виправлення: Починайте з сервер-рендереного «лише активна» стану, якщо можливо (наприклад, додайте hidden), або застосуйте невеликий inline-клас перед paint — якщо CSP дозволяє — або використайте defer плюс CSS, який ховає панелі лише коли є клас «JS-enabled».
5) «Акордеон «лише одна відкрита» сам закривається непередбачувано»
Симптом: Відкриваючи панель, вона закривається відразу, або відкривання однієї закриває інший акордеон в іншому місці сторінки.
Корінь: Делегування подій підключено до document без обмеження області; хендлер збирає details по всій сторінці.
Виправлення: Скопуйте підключення до контейнера з data-accordion; закривайте лише сусідів у цьому контейнері.
6) «Скрінрідер оголошує дивні ролі або дублює заголовки»
Симптом: Таб-панелі оголошуються навіть коли приховані; вкладки оголошуються як посилання; повторювані підписи.
Корінь: Приховання лише через CSS; неправильні ARIA-зв’язки; нелогічне використання display:none; повторне використання ID в кількох tablist-ах.
Виправлення: Використовуйте hidden для неактивних табпанелей; гарантуйте унікальні ID для кожного екземпляру; перевіряйте пари aria-controls і aria-labelledby.
Три корпоративні міні-історії (анонімізовано)
Інцидент: неправильне припущення «JS завжди завантажується»
Середня B2B SaaS компанія мала сторінку «Відновлення акаунта» з вкладками: «Email», «Authenticator», «SSO fallback». Продукт хотів стильний вигляд, тож команда використала компонент з основного бандла. У staging було все добре, в офісі по Wi‑Fi — теж, в локальній розробці — норм, бо все миттєво й ніхто не тримає п’ять VPN на ноутбуці.
Потім мережа клієнта почала блокувати сторонній домен, який використовувався якимось аналітичним скриптом. Браузер чекав, повторював запити й відкладав виконання головного бандла по-різному в залежності від пристрою. Для частини користувачів вкладковий UI підвантажувався занадто пізно, і базовий HTML — по суті порожні плейсхолдери — був єдиним, що вони бачили. Варіантів відновлення не було видно. Користувачі не могли доступитись й обурились — і це дуже неприємний тикет отримати.
Неправильне припущення було не в тому, що «аналітика може бути заблокована» — це відомо. Неправильне припущення було в тому, що критична взаємодія може бути клієнт-тільки. Виправлення не було героїчним: сервер-рендерити контент відновлення як нормальні секції з анкер-навiгацією, а вкладки додавати лише якщо JS завантажено. Наступного разу, коли корпоративний проксі вийшов з ладу, відновлення все одно працювало. Ніхто не писав панічних тикетів у on-call.
Оптимізація, що зіграла зле: CSS-only вкладки з radio
Команда e‑commerce вирішила прибрати JavaScript з табів на сторінці товару («Опис», «Специфікація», «Гарантія»). Гарна ідея. На жаль, вони обрали трюк з radio-кнопками, а потім впакували це як partial, що використовувався по всьому сайту — включно зі сторінкою порівняння, де одночасно рендерилось 20 товарів.
Radio-input вимагають унікальних груп name і унікальних ID, щоби мітки були прикріплені до відповідного input. Partial мав статичні ID, бо «то всього компонент». На сторінці одного товару все виглядало добре. На сторінці порівняння клік по «Специфікації» товару A переключав панель Warranty у товарі B. Користувачі думали, що сторінку «проклято». І це було технічно правильно.
Виправлення — перестати вважати, що CSS-only означає відсутність складності. Вони перейшли на anchor-based базу (кожна секція отримала id) і маленьке scoped-покращення, що оновлювало лише конкретний контейнер у вкладки. ID іменувалися з ідентифікатором товару. Оптимізація стала реальним поліпшенням: менше JS, ніж у фреймворк-компоненті, і більше коректності, ніж у CSS-хаку.
Нудна, але правильна практика, що врятувала день: scoping і дефолти
У фінансовій компанії аудит безпеки вимагав суворішого CSP. Inline-скрипти були заборонені. Деякі команди панікували, бо їхні UI-компоненти покладались на inline-ініціалізацію віджета на сторінці. Одна команда не панікувала, бо їхні патерни базувалися на поступовому покращенні з зовнішніми скриптами й суворим scoping-ом.
Їхні сторінки з великою кількістю дисклозерів використовували нативні <details>. Покращення (поведінка «одна відкрита», deep-link, аналітика) були розміщені як версійні статичні файли з defer. Ініціалізація була контейнерною: кожен акордеон мав data-accordion, кожен tablist був локальний, а слухачі подій делегувалися в межах компонента.
Коли CSP змінився, ці сторінки продовжили працювати. Ніяких екстрених запитів на винятки в заголовках. Ніяких «тимчасових» послаблень, що перетворюються на постійні. Просто нудний зелений деплой. У корпоративному середовищі нудність — це фіча, за яку варто боротися.
Чек-листи / покроковий план
Покроково: як випустити акордеон стійко
- Почніть з HTML: використовуйте
<details>/<summary>. Вкладайте реальний контент. Жодних плейсхолдерів як єдиного джерела істини. - Визначте стан за замовчуванням: додайте
openна ту панель, яку хочете бачити відкритою за замовчуванням (або жодну). - Стайлінг уважно: зберігайте видимий фокус; не втрачайтe семантику; уникайте інтерактивних контролів всередині
summary. - Додавайте покращення тільки за потреби: «одна відкрита» поведінка, deep-link, аналітика. Тримайте кожне покращення незалежним.
- Відкладіть скрипти: додавайте
deferі тримайте enhancement-скрипти малими й локальними. - Тестуйте режими відмови: JS відключений, CSS відключений (або заблокований), повільна мережа. Переконайтеся, що контент залишається доступним.
- Фіксуйте ID: забезпечте унікальні, стабільні ID, якщо потрібні deep links або аналітика.
Покроково: підняття anchor-навігації до вкладок
- Базові секції: використовуйте
<section id="..."><h2>...</h2>для кожної панелі. - Базова навігація: надайте
<nav>зі списком посилань на ці ID. - Шар покращення: замінюйте/доповнюйте навігацію
role="tablist"кнопками, коли JS доступний. - Правильне приховання панелей: використовуйте
hiddenна неактивних панелях, а не лише CSS. - Підтримка клавіатури: стрілки, Home/End. Не ігноруйте це; це частина віджета.
- Deep-linking: використовуйте хеш для вибору вкладки. Не пуште історію при завантаженні; замінюйте стан при дії користувача.
- Скопуйте все: один віджет вкладок не повинен впливати на інший. Уникайте глобальних селекторів, що ловлять усі панелі на сторінці.
Чек-лист релізу для продакшн-систем
- Чи може користувач дістатися контенту при вимкненому JS?
- Чи може користувач оперувати контролем тільки з клавіатури?
- Чи хеш-посилання відкривають потрібну панель/вкладку?
- Чи унікальні ID по сторінці?
- Чи скрипти відкладені й кешовані?
- Чи компонент працює, якщо аналітика впаде?
- Чи тестували принаймні один профіль «повільний 3G / висока затримка»?
- Чи enhancement-код обмежений контейнером (без випадкового глобального зв’язування)?
FAQ
1) Чи завжди мені варто використовувати details/summary для акордеонів?
Так для типової дисклозерної контенту. Якщо потрібна складна координація станів, вкладені інтерактивні заголовки або кастомна семантика, можливо, ви будете будувати кастомний компонент — але тоді ви свідомо берете на себе більше тестування і ризиків.
2) Чи можна стилізувати маркер summary?
Можна, але обережно. Якщо ви видаляєте стандартний маркер, ви повинні замінити афорданс (чіткий індикатор) і зберегти видимість фокусу. Не ховайте єдину підказку, що щось можна розгорнути.
3) Чому не використовувати компонент акордеона з UI-бібліотеки?
Іноді варто — якщо ви вже залежите від бібліотеки й компонент сильно кастомізований. Але якщо ви підтягуєте бібліотеку головно заради дисклозерів, ви платите постійний податок: розмір бандла, баги гідратації і хаос залежностей.
4) Чи можливі вкладки без JavaScript?
Не як «реальні вкладки» з таб-семантикою і клавіатурними конвенціями. Базою мають бути анкер-навігація або окремі сторінки. Потім за потреби підсилюйте їх JS.
5) Чи повинні кнопки вкладок бути посиланнями?
Зазвичай — ні. Вкладки — це контроли; використовуйте <button> з role="tab". Якщо ви хочете навігаційну поведінку, використовуйте посилання і не називайте це вкладками. Змішування двох підходів створює заплутану поведінку для користувачів і допоміжних технологій.
6) Як обробляти deep links для вкладок?
Використовуйте хеш для представлення вибору вкладки (наприклад, #tab-security). На завантаженні читайте хеш і активуйте відповідну вкладку. При кліку користувача оновлюйте хеш через history.replaceState, щоби не засмічувати історію браузера.
7) Який найнадійніший спосіб забезпечити «лише одна відкрита» у акордеоні?
Слухайте подію toggle на контейнері й закривайте сусідні details, коли один відкривається. Обов’язково скоупуйте до одного акумулятора акордеонів, щоб не закривати невідповідні дисклозери в інших місцях сторінки.
8) Чи можна вкладати details елементи?
Можна, але це легко породжує заплутану взаємодію й шляхи фокусу. Якщо вкладати, тримайте summaries простими, уникайте вкладених контролів в заголовках і ретельно тестуйте клавіатурну навігацію.
9) Як уникнути мерехтіння при покращенні до вкладок?
Або рендерьте початковий «лише активний» стан на сервері за допомогою hidden, або застосуйте клас «js-enabled» рано і ховайте панелі лише коли цей клас присутній. Не чекайте, поки пізній JS приховає контент після layout.
10) Який мінімальний план тестування перед релізом?
Керування тільки з клавіатури, доступ до контенту при відключеному JS, перевірка унікальних ID і принаймні один профіль повільної мережі. Якщо цього немає — ви випускаєте надії замість продукту.
Висновок: наступні кроки, які не переслідуватимуть вас
Якщо запам’ятати одне: починайте з HTML, що вже працює. <details>/<summary> — ваш друг для дисклозерів. Вкладки потребують JavaScript, але не вимагають фреймворка — і вони точно не потребують клієнт-тільки контенту.
Практичні наступні кроки:
- Виберіть одну високонавантажену сторінку з акордеоном (FAQ, ціни, довідка в налаштуваннях). Замініть бібліотечний акордеон на нативні
<details>і виміряйте зменшення бандла. - Для існуючих таб-віджетів переконайтеся, що є базова anchor-навігація та секції. Потім поверніть вкладки як enhancement з локалізованим JS.
- Запустіть завдання діагностики у CI: перевірку дубльованих ID і render-blocking скриптів на сторінках з великим контентом.
- Запишіть «контракт компонента» вашої команди: базова поведінка без JS, поведінка покращення з JS і прийнятні режими відмови.
Зробіть це — і ваш UI не лише виглядатиме добре. Він працюватиме, коли з’явиться реальний світ, а саме це і роблять продакшн-системи.