Uploader

Zona de drop e starea goală a unui slot cu înălțime fixă. Când apar fișiere, zona dispare și în locul ei intră lista plus butonul „Adaugă documente" — aceeași înălțime, deci layoutul nu sare.

Markup canonic · components/uploader.json

sursa = JSON · generat de scripts/build-catalog.py
Încarcă un document
sau trage-l aici

Contractul de înălțime · --up-h

gol — zona = 218px
Încarcă un document
sau trage-l aici
plin, un fișier — container + gap 14 + buton = aceiași 218px
bon-fiscal-aprilie.jpg
Am primit fișierul. · 840 KB

Stările zonei

.uploader-zone.is-over — contur continuu, copy „Lasă documentul aici"
Lasă documentul aici
sau trage-l aici
.uploader-zone.is-disabled — copy neschimbat, doar tratament vizual
Încarcă un document
sau trage-l aici

Starea plină · .uploader-filled

încărcare — .uploader-track + procent în meta
bon-fiscal-aprilie.jpg
Se încarcă · 62% din 840 KB
succes + eroare pe rând · fără banner duplicat
contract-servicii-2026.pdf
Am primit fișierul. · 2,4 MB
extras-cont-martie.pdf
Ceva n-a mers. Încearcă din nou.

Suprafață moștenită

pe alb (card) — fundal transparent
Încarcă un document
sau trage-l aici
pe bej-1 — același markup, zero override
Încarcă un document
sau trage-l aici

Compact · .uploader.is-compact

zona = 64px, exact înălțimea unui .uploader-item · rx="9.5" pe contur
Încarcă un document
sau trage-l aici
compact plin — containerul dispare, rândurile stau direct pe suprafață
bon-fiscal-aprilie.jpg
Am primit fișierul. · 840 KB

Reguli

Contractul de înălțime

Un singur slot: --up-h 218px (64px pe .is-compact). Gol = zona; plin = .uploader-filled (flex: 1) + gap 14px + butonul de adăugare, aceeași înălțime. Doar de la al doilea fișier containerul crește.

Zona dispare după upload

Post-upload rămân doar lista și „Adaugă documente" — zona nu rămâne deasupra listei. Drag-and-drop peste containerul plin poate rămâne wireuit, dar fără zonă vizibilă.

Suprafață moștenită

Zona are fundal transparent și moștenește suprafața de sub ea (card alb, --c-bej-0, --c-bej-1). Albul apare doar pe rândul de fișier. Un background: #fff pe zonă e drift.

Conturul e SVG

.uploader-outline, 1px, dash 8, gap 6 — nu border: dashed: CSS-ul nu controlează dash și gap separat. rx="23.5" pe default, rx="9.5" pe compact.

Eroarea trăiește pe rând

O singură dată, pe .uploader-item.is-error — fără banner sau .alert lângă listă și fără badge de status în nume. „Reîncearcă" apare doar la eroare de rețea; tipul sau mărimea nu se rezolvă prin reîncercare.

Ștergerea din rând

.btn.danger.outline.xs.icon-only cu aria-label="Elimină" — contur, nu fill negru: butonul se repetă pe fiecare rând. Vezi docs/components/button.md.

Tastatură și inputul real

Zona e role="button" tabindex="0"; Enter și Space deschid pickerul. Inputul (.uploader-input) e frate al slotului și ascuns vizual, nu cu display: none — un input în interiorul zonei ar re-intra în handler-ul ei.

Regulile complete

Mesajele de eroare verbatim, stările, accesibilitatea și anti-patterns: docs/components/uploader.md. Contractul mașin-lizibil: components/uploader.json.

Live · alege sau trage un fișier

componenta reală, wireuită · uploadul e simulat (fără endpoint), validarea și stările sunt cele din contract
Încarcă un document
sau trage-l aici

Wiring · picker, drag-over, progres

scheletul de comportament — înlocuiește simularea cu POST-ul real al aplicației
// Inputul e FRATE al slotului, nu copil al zonei: handler-ul zonei apelează
// input.click(), iar un input în interiorul zonei ar re-intra în același handler.
var zone  = root.querySelector('.uploader-zone');
var input = root.querySelector('.uploader-input');

zone.addEventListener('click', function () { input.click(); });
zone.addEventListener('keydown', function (e) {
  if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); input.click(); }
});

// preventDefault pe amândouă, altfel browserul navighează la fișier
['dragenter', 'dragover'].forEach(function (ev) {
  zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.add('is-over'); });
});
['dragleave', 'drop'].forEach(function (ev) {
  zone.addEventListener(ev, function (e) { e.preventDefault(); zone.classList.remove('is-over'); });
});
zone.addEventListener('drop', function (e) { accept(e.dataTransfer.files); });
input.addEventListener('change', function () { accept(input.files); input.value = ''; });

// Validarea rulează ÎNAINTE de upload, dar fișierul respins intră totuși în
// listă, ca rând de eroare — nu dispare în silence.
function validate(file) {
  if (!/\.(pdf|jpe?g|png)$/i.test(file.name)) return 'Tipul fișierului nu e acceptat.';
  if (file.size > 10 * 1024 * 1024)          return 'Fișierul e prea mare. Maxim 10 MB.';
  return null;
}

// Progresul vine din XHR; .uploader-fill primește width = procent, iar
// .uploader-meta textul „Se încarcă · 62% din 840 KB".
var xhr = new XMLHttpRequest();
xhr.upload.onprogress = function (e) {
  if (!e.lengthComputable) return;
  var pct = Math.round(e.loaded / e.total * 100);
  fill.style.width = pct + '%';
  meta.textContent = 'Se încarcă · ' + pct + '% din ' + size;
};
xhr.onload  = function () { done(row); };                       // thumb .is-done + „Am primit fișierul."
xhr.onerror = function () { fail(row, 'Ceva n-a mers. Încearcă din nou.', true); };  // + „Reîncearcă"
xhr.open('POST', '/api/documents');
xhr.send(formData);