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
Contractul de înălțime · --up-h
Stările zonei
Starea plină · .uploader-filled
Suprafață moștenită
Compact · .uploader.is-compact
Reguli
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.
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ă.
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.
.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.
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.
.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.
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.
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
Wiring · picker, drag-over, progres
// 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);