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)Stări
height: 0 la height: auto, deci acceptă orice înălțime de conținut.Markup de referință
.acc-item[data-state] → button.acc-trigger cu aria-expanded + aria-controls → .acc-panel[role="region"] cu aria-labelledby. Wrapperele .acc-panel-inner și .acc-panel-body sunt obligatorii — primul e cel măsurat pentru animație, al doilea duce padding-ul.
.acc-lead (icon 20×20) · .acc-sub (al doilea rând) · badge în .acc-title · .acc-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. .acc-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ă.
var triggers = Array.prototype.slice.call(root.querySelectorAll('.acc-trigger'))
.filter(function (b) { return b.closest('[data-accordion]') === root; });
function panelOf(item) { return item.querySelector('.acc-panel'); }
// 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');
item.querySelector('.acc-trigger').setAttribute('aria-expanded', 'true');
var target = p.firstElementChild.offsetHeight;
void p.offsetHeight; // reflow: fixează punctul de start
p.style.height = target + 'px';
onEnd(p, function () { p.style.height = 'auto'; }); // conținut variabil
}
function closeItem(item) {
var p = panelOf(item);
clearPending(p);
p.style.height = p.offsetHeight + 'px';
void p.offsetHeight;
item.setAttribute('data-state', 'closed');
item.querySelector('.acc-trigger').setAttribute('aria-expanded', 'false');
p.style.height = '0px';
onEnd(p, function () { p.style.height = ''; }); // revine pe regula CSS
}
triggers.forEach(function (btn) {
btn.addEventListener('click', function () {
var item = btn.closest('.acc-item');
var isOpen = item.getAttribute('data-state') === 'open';
if (single && !isOpen) root.querySelectorAll('.acc-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(); }
});
});
});