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

sursa = JSON · generat de scripts/build-catalog.py
Încarcă documentul din Documente și apasă „Trimite la interpretare". Primești rezultatul în câteva minute.

Flush (default) · .accordion

hairline între rânduri, fără container · liste lungi, FAQ, secțiuni de setări în card
Încarcă documentul din Documente și apasă „Trimite la interpretare". Primești rezultatul în câteva minute, iar factura intră automat în raportul lunii.
Îl adaugi oricând până la închiderea lunii. După închidere, bonul intră în luna curentă.
Interpretarea e inclusă în abonament. Nu plătești per document.

Două rânduri + badge + meta

.acc-title (rând 1) + .acc-sub (rând 2) · badge în titlu · .acc-meta la dreapta, înaintea chevronului
12 facturi emise în august, toate încasate. Detaliile fiecărei facturi sunt în tabelul din Documente.
Trei bonuri nu au categorie: combustibil, protocol și necategorizat. Alege categoria pentru fiecare, apoi luna se poate închide.
Toate declarațiile depuse la termen. Nu mai e nimic de făcut până la 25 august.

Contained · .accordion.contained

fiecare item e card alb cu border · obiecte independente, 3–6 rânduri, conținut mai bogat în panel
Alegi pe ce canal te anunțăm înainte de fiecare termen fiscal.
Contabilul are acces la documentele încărcate și la rapoarte, nu la setările de plată.

Dens · .accordion.sm + .chevron-start

44px hit target minim · sidebar, sub-rânduri de tabel, doc sheet mobil · chevron la început pentru grupuri (doar order — în DOM rămâne ultimul)
Opt documente, dintre care trei fără categorie.
Luna închisă. Documentele rămân doar în citire.
Luna închisă. Documentele rămân doar în citire.

Stări

închis · deschis ([data-state="open"] + aria-expanded="true") · dezactivat (button disabled) · hover = rândul se ridică (fundal --c-bej-0 opac + hairline-uri pe --c-rule-hover)
Conținut ascuns.
Conținut vizibil. Panelul trece de la height: 0 la height: auto, deci acceptă orice înălțime de conținut.
Inaccesibil.

Markup de referință

Structura minimă

.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.

Sloturi opționale

.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ă.

Deschidere

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.

Alegerea variantei

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.

Tastatură

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.

Mobil

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

componenta e CSS-first în stare (height 0 / auto) · JS-ul comută data-state, aria-expanded și animă înălțimea în px · acesta e exact scriptul rulat de pagină — copiază-l întreg
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(); }
    });
  });
});