Components · 폼
File Upload (파일 업로드)
파일을 드래그앤드롭 또는 "파일 선택" 버튼으로 첨부받는 폼 컴포넌트. 카드뉴스·publicid-bot 등 GUI 도구의 파일 첨부 공통 규격이다.
정의
드롭존과 네이티브 <input type="file">을 함께 제공해 파일을 첨부받는 폼 컴포넌트다.
Anatomy
| 부위 | 토큰 |
|---|---|
| ① 드롭존 | 보더 --upload-border(점선)·활성 --upload-border-active(브랜드) · bg --upload-bg/--upload-bg-active · 모서리 --upload-radius · 패딩 --upload-pad |
| ② 업로드 아이콘 | 라인 SVG, --upload-icon-fg(브랜드), stroke="currentColor" |
| ③ 파일 선택 버튼 | 네이티브 <input type="file">을 시각 스타일링(Button 규격 재사용), 히트영역 --target-min |
| ④ 파일 목록 | 파일명·크기·삭제 버튼 한 줄. 텍스트 --text-body-sm · 삭제 히트영역 --target-min |
변형
| 변형 | 용도 |
|---|---|
| 단일 파일 | 파일 1개 첨부(로고·증빙) — 선택 후 교체만 |
| 다중 파일 | 여러 파일 누적 첨부(카드뉴스 이미지 세트) — multiple |
| 이미지형 | 첨부 즉시 썸네일 미리보기를 목록에 함께 노출 |
상태
| 상태 | 값 |
|---|---|
| Default(Idle) | 보더 --upload-border · bg --upload-bg |
| Dragover | 보더 --upload-border-active · bg --upload-bg-active(파일을 끌어 올렸을 때) |
| Focus(버튼) | box-shadow: var(--focus-ring) |
| Disabled | opacity: var(--opacity-disabled) + cursor: not-allowed |
| Error(용량·형식 초과) | 도움말 텍스트 --color-state-danger-fg |
Do / Don't
Do
- 드래그앤드롭과 "파일 선택" 버튼을 항상 함께 제공한다 — 드래그만 있으면 키보드 사용자는 첨부할 수 없다.
- 허용 형식·최대 용량을 드롭존 안내 문구에 미리 밝힌다("JPG·PNG, 10MB 이하").
- 첨부한 파일은 이름·크기·삭제 버튼을 한 줄 목록으로 보여 준다.
Don't
- 드래그앤드롭만 지원하지 말 것 — 접근성 위반이며 모바일에서 동작하지 않는다(KRDS 규칙).
- 업로드 실패를 색만으로 알리지 말 것 — 사유 텍스트를 함께 보여 준다.
- 선택 즉시 자동 전송하지 말 것 — 사용자가 목록을 확인·삭제할 여지를 남긴다.
콘텐츠 규칙
- 안내 문구는 행동+제약 한 줄: "파일을 끌어 놓거나 선택하세요 (JPG·PNG, 10MB 이하)".
- 버튼 라벨은 동사형 "파일 선택"(명사 단독 "파일" ✗).
- 오류 메시지는 사유+해결을 한 문장으로: "10MB를 초과했습니다. 파일을 줄여 다시 시도하세요."
- 파일 크기 단위는 숫자에 붙여 쓴다(
2.4MB·980KB).
키보드 인터랙션
| 키 | 동작 |
|---|---|
Tab | "파일 선택" 버튼(네이티브 input)으로 포커스 이동 |
Enter / Space | 파일 선택 대화상자 열기 |
Tab → Enter | 목록의 삭제(✕) 버튼으로 이동 후 삭제 |
접근성
- 파일 선택은 네이티브
<input type="file">을 시각적으로 스타일링한다(완전 커스텀 구현 지양) — 키보드·스크린리더 기본 지원. - 드롭존은 보조 수단일 뿐 — 모든 첨부는 네이티브 input으로도 가능해야 한다(KRDS 규칙).
- 삭제 버튼은
aria-label="삭제"(대상 파일명 포함 권장) + 히트영역--target-min. - 업로드 진행·완료·오류는
aria-live="polite"영역으로 알린다. 기준:../guidelines/accessibility.md.
코드
html
<div class="pi-upload" data-upload>
<input class="pi-upload__input" id="pi-upload-demo" type="file" multiple accept="image/*">
<label class="pi-upload__zone" for="pi-upload-demo">
<svg class="pi-upload__icon" viewBox="0 0 24 24" width="34" height="34" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 15V4"/><path d="M8 8l4-4 4 4"/><path d="M4 15v4a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-4"/>
</svg>
<span class="pi-upload__hint">파일을 끌어 놓거나 <b>선택</b>하세요</span>
<span class="pi-upload__note">JPG·PNG, 10MB 이하</span>
</label>
<ul class="pi-upload__list" aria-live="polite"></ul>
</div>css
.pi-upload__input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
.pi-upload__zone {
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--space-2);
padding: var(--upload-pad);
border: var(--border-medium) dashed var(--upload-border);
border-radius: var(--upload-radius);
background: var(--upload-bg);
color: var(--color-text-secondary);
text-align: center; cursor: pointer; word-break: keep-all;
transition: border-color var(--duration-base) var(--easing-out), background var(--duration-base) var(--easing-out);
}
.pi-upload__icon { color: var(--upload-icon-fg); }
.pi-upload__hint { font: var(--text-body); letter-spacing: var(--text-body-letter-spacing); }
.pi-upload__note { font: var(--text-caption); color: var(--color-text-muted); }
.pi-upload__input:focus-visible + .pi-upload__zone { outline: none; box-shadow: var(--focus-ring); }
.pi-upload.is-dragover .pi-upload__zone { border-color: var(--upload-border-active); background: var(--upload-bg-active); }
.pi-upload__input:disabled + .pi-upload__zone { opacity: var(--opacity-disabled); cursor: not-allowed; }
.pi-upload__list { list-style: none; margin: var(--space-4) 0 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2); }
.pi-upload__file {
display: flex; align-items: center; gap: var(--space-3);
padding: var(--space-3) var(--space-4);
border: var(--border-thin) solid var(--color-border-subtle);
border-radius: var(--radius-sm);
font: var(--text-body-sm);
}
.pi-upload__name { flex: 1 1 auto; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pi-upload__size { color: var(--color-text-muted); }
.pi-upload__remove { min-width: var(--target-min); min-height: var(--target-min); background: none; border: none; cursor: pointer; color: var(--color-text-muted); }
.pi-upload__remove:focus-visible { outline: none; box-shadow: var(--focus-ring); border-radius: var(--radius-sm); }
@media (prefers-reduced-motion: reduce) { .pi-upload__zone { transition: none; } }js
// 드래그 상태 토글 + 파일 목록 렌더 (의존성 없음)
document.querySelectorAll("[data-upload]").forEach((root) => {
const input = root.querySelector(".pi-upload__input");
const zone = root.querySelector(".pi-upload__zone");
const list = root.querySelector(".pi-upload__list");
const fmt = (n) => (n > 1e6 ? (n / 1e6).toFixed(1) + "MB" : Math.round(n / 1e3) + "KB");
const render = (files) => {
list.replaceChildren();
[...files].forEach((f) => {
const li = document.createElement("li");
li.className = "pi-upload__file";
li.innerHTML = '<span class="pi-upload__name"></span><span class="pi-upload__size"></span>'
+ '<button class="pi-upload__remove" type="button" aria-label="삭제">✕</button>';
li.querySelector(".pi-upload__name").textContent = f.name;
li.querySelector(".pi-upload__size").textContent = fmt(f.size);
list.appendChild(li);
});
};
input.addEventListener("change", () => render(input.files));
["dragenter", "dragover"].forEach((e) => zone.addEventListener(e, (ev) => { ev.preventDefault(); root.classList.add("is-dragover"); }));
["dragleave", "drop"].forEach((e) => zone.addEventListener(e, () => root.classList.remove("is-dragover")));
zone.addEventListener("drop", (ev) => { ev.preventDefault(); render(ev.dataTransfer.files); });
list.addEventListener("click", (ev) => { const t = ev.target.closest(".pi-upload__remove"); if (t) t.closest(".pi-upload__file").remove(); });
});Changelog
| 날짜 | 변경 |
|---|---|
| 2026-07-15 | 문서 계약(10섹션) 적용 — KRDS 벤치마킹 규격(2026-07-15) 승계, Anatomy 도해·드래그앤드롭+네이티브 input 병행 코드·상태·접근성 신설 |