/* ============================================================================= * ui_template_drop.ts * 부품 「파일 놓기 칸」(552-3 · 목록 34) — 파일을 끌어 놓거나 [파일 고르기]로 고르는 점선 칸. * 꼴 = 점선 칸(올림 아이콘 · 한 줄 · 고르기 단추 · 받는 꼴 안내) · 진행(`setProgress` → 진행 막대) · 오류(`setError` → 빨간 글) * 크기 = 칸 최소 48 · 단추 28 * 상태 = 기본 · 마우스(파일을 끌어 옴 = .is-over) · 막힘(`disabled`) · 오류(.is-error) * 누름 = 칸 어디든 누름 · 칸에 초점 두고 Enter · Space = 파일 고르기(옛 B03 놓기 칸 동작) · [파일 고르기] 단추는 같은 일 * 쓰는 법 = `const drop = createDrop({ accept: ".las,.laz", onFiles: (files) => upload(files) }); host.append(drop.root)` * 대신하는 옛 변종 = B03 `b03-file__dropzone` · CAD `body.cad-file-dragging::after` · 숨은 file + 단추(B01 · B08 · M02 · cad_host · help_edit) * ========================================================================== */ import { createButton } from "./ui_template_button"; import { el } from "./ui_template_elements_base"; import { icon } from "./ui_template_icons"; import { createProgressBar } from "./ui_template_progress"; import "./ui_template_drop.css"; export interface DropOptions { onFiles: (files: File[]) => void; /** input accept 글(`.las,.laz` · `image/*`) */ accept?: string; multiple?: boolean; text?: string; /** 받는 꼴 · 크기 안내 */ hint?: string; disabled?: boolean; } export interface DropHandle { root: HTMLElement; /** 진행 막대 — null 이면 숨김 */ setProgress: (ratio: number | null, label?: string) => void; /** 오류 글 — null 이면 지움 */ setError: (message: string | null) => void; setDisabled: (on: boolean) => void; } export function createDrop(opts: DropOptions): DropHandle { const input = el("input", { attrs: { type: "file" } }); input.hidden = true; if (opts.accept) input.accept = opts.accept; input.multiple = !!opts.multiple; const pick = createButton({ label: "파일 고르기", variant: "ghost", size: 28, }); const bar = createProgressBar({ thick: 6 }); bar.root.hidden = true; const error = el("p", { className: "ui-drop__error" }); error.hidden = true; const root = el("div", { className: "ui-drop", attrs: { role: "button" }, children: [ el("span", { className: "ui-drop__icon", children: [icon("upload", 24)] }), el("p", { className: "ui-drop__text", text: opts.text ?? "파일을 여기에 끌어 놓거나" }), pick, input, ], }); if (opts.hint) root.append(el("p", { className: "ui-drop__hint", text: opts.hint })); root.append(bar.root, error); const give = (list: FileList | null | undefined): void => { const files = [...(list ?? [])]; if (files.length && !pick.disabled) opts.onFiles(opts.multiple ? files : files.slice(0, 1)); }; input.addEventListener("change", () => { give(input.files); input.value = ""; // 같은 파일을 다시 골라도 change }); // 단추 누름도 칸으로 올라와 한 번만 엶 · 숨은 input 의 click 이 올라온 것은 거름 root.addEventListener("click", (ev) => { if (ev.target !== input && !pick.disabled) input.click(); }); root.addEventListener("keydown", (ev) => { if (ev.target !== root || (ev.key !== "Enter" && ev.key !== " ")) return; ev.preventDefault(); if (!pick.disabled) input.click(); }); const over = (on: boolean): boolean => root.classList.toggle("is-over", on && !pick.disabled); root.addEventListener("dragover", (ev) => { ev.preventDefault(); over(true); }); root.addEventListener("dragleave", (ev) => { if (!root.contains(ev.relatedTarget as Node | null)) over(false); }); root.addEventListener("drop", (ev) => { ev.preventDefault(); over(false); give(ev.dataTransfer?.files); }); const setDisabled = (on: boolean): void => { pick.disabled = on; root.tabIndex = on ? -1 : 0; root.setAttribute("aria-disabled", String(on)); }; setDisabled(!!opts.disabled); return { root, setProgress: (ratio, label) => { bar.root.hidden = ratio === null; if (ratio !== null) bar.set(ratio, label); }, setError: (message) => { error.hidden = !message; error.textContent = message ?? ""; root.classList.toggle("is-error", !!message); }, setDisabled, }; }