Accordion
Disclosure pentru conținut secundar: rândul rămâne vizibil, detaliul se deschide la cerere. Trei variante pe aceeași anatomie — flush pentru liste lungi, contained pentru obiecte independente, sm pentru contexte dense.
Markup canonic · components/accordion.json
Flush (default) · .accordion
Două rânduri + badge + meta
Contained · .accordion.contained
Dens · .accordion.sm + .chevron-start
order — în DOM rămâne ultimul)Buton în loc de rând · .accordion.action
.accordion-header e un div, iar aria-expanded/aria-controls stau pe .btn · fără chevron, fără hover de rândStări
height: 0 la height: auto, deci acceptă orice înălțime de conținut.Markup de referință
.accordion-item[data-state] → button.accordion-trigger cu aria-expanded + aria-controls → .accordion-content[role="region"] cu aria-labelledby. Wrapperele .accordion-content-inner și .accordion-content-body sunt obligatorii — primul e cel măsurat pentru animație, al doilea duce padding-ul.
.accordion-lead (icon 20×20) · .accordion-sub (al doilea rând) · badge în .accordion-title · .accordion-meta (sumă sau dată, tabular). Ordinea vizuală nu se schimbă niciodată.
Implicit multi-deschis. Pentru exclusiv (un singur item deschis) adaugi data-single pe container. Primul item deschis din start doar dacă răspunde la întrebarea principală a ecranului.
flush pentru liste lungi de întrebări sau setări (peste 5 rânduri) · contained pentru 3–6 obiecte independente cu formular sau conținut bogat · sm pentru sidebar, sub-rânduri și sheet mobil.
button nativ: Enter și Space comută. Săgeți sus/jos mută focusul între triggerele aceluiași accordion, Home/End la primul și ultimul. Focus ring: outline 2px fog, inset.
Hit target ≥ 44px în toate variantele. Pe sub 640px padding-ul orizontal scade la 4px (flush) ca titlul să se alinieze cu conținutul paginii. .accordion-sub.clamp ține subtitlul pe un rând.
Comportament — JS minim
document.querySelectorAll('[data-accordion]').forEach(function (root) {
var single = root.hasAttribute('data-single');
// Scoping: un accordion imbricat își gestionează propriile rânduri, altfel
// săgețile ar sări din părinte în copil și data-single ar închide și afară.
// .accordion-action = varianta cu buton: butonul ESTE trigger-ul (butoanele nu
// se cuibăresc, deci nu stă în .accordion-trigger — îl înlocuiește).
var triggers = Array.prototype.slice.call(root.querySelectorAll('.accordion-trigger, .accordion-action'))
.filter(function (b) { return b.closest('[data-accordion]') === root; });
function panelOf(item) { return item.querySelector('.accordion-content'); }
function ctrlOf(item) { return item.querySelector('.accordion-trigger, .accordion-action'); }
// O singură tranziție în lucru per panel: handler-ul anterior se anulează,
// altfel un transitionend rămas din deschidere repune height:auto la închidere.
// Timer-ul de rezervă aplică aceeași stare finală când transitionend nu vine
// (tab ascuns, element nepictat, tranziție întreruptă).
function clearPending(p) {
if (p._accEnd) { p.removeEventListener('transitionend', p._accEnd); p._accEnd = null; }
if (p._accTimer) { clearTimeout(p._accTimer); p._accTimer = null; }
}
function onEnd(p, fn) {
clearPending(p);
p._accEnd = function (e) { if (e.propertyName !== 'height') return; clearPending(p); fn(); };
p.addEventListener('transitionend', p._accEnd);
p._accTimer = setTimeout(function () { clearPending(p); fn(); }, 260); // 220ms + 40
}
function openItem(item) {
var p = panelOf(item);
clearPending(p);
p.style.height = p.offsetHeight + 'px'; // de la înălțimea curentă
item.setAttribute('data-state', 'open');
ctrlOf(item).setAttribute('aria-expanded', 'true');
var target = p.firstElementChild.offsetHeight;
void p.offsetHeight; // reflow: fixează punctul de start
p.style.height = target + 'px';
swapLabel(item, true);
onEnd(p, function () { p.style.height = 'auto'; }); // conținut variabil
}
// data-label-open pe buton: eticheta se schimbă la deschidere („Completează" →
// „Ascunde"). Opțional — fără el, starea rămâne doar în aria-expanded.
function swapLabel(item, open) {
var b = item.querySelector('.accordion-action[data-label-open]');
if (!b) return;
if (open) { b.dataset.labelClosed = b.textContent.trim(); b.textContent = b.dataset.labelOpen; }
else if (b.dataset.labelClosed) { b.textContent = b.dataset.labelClosed; }
}
function closeItem(item) {
var p = panelOf(item);
clearPending(p);
p.style.height = p.offsetHeight + 'px';
void p.offsetHeight;
item.setAttribute('data-state', 'closed');
ctrlOf(item).setAttribute('aria-expanded', 'false');
p.style.height = '0px';
swapLabel(item, false);
onEnd(p, function () { p.style.height = ''; }); // revine pe regula CSS
}
triggers.forEach(function (btn) {
btn.addEventListener('click', function () {
var item = btn.closest('.accordion-item');
var isOpen = item.getAttribute('data-state') === 'open';
if (single && !isOpen) root.querySelectorAll('.accordion-item[data-state="open"]').forEach(closeItem);
if (isOpen) closeItem(item); else openItem(item);
});
// Enter / Space vin gratis din button. Săgețile mută doar focusul, nu
// deschid — deschiderea rămâne o acțiune explicită.
btn.addEventListener('keydown', function (e) {
var i = triggers.indexOf(btn), t = null;
if (e.key === 'ArrowDown') t = triggers[(i + 1) % triggers.length];
else if (e.key === 'ArrowUp') t = triggers[(i - 1 + triggers.length) % triggers.length];
else if (e.key === 'Home') t = triggers[0];
else if (e.key === 'End') t = triggers[triggers.length - 1];
if (t) { e.preventDefault(); t.focus(); }
});
});
});