Files
Aislo/ui_template/ui_template_drop.ts
T
eomsangdonandClaude Opus 5.5 877b89f37c fix(ui-kit): 부품 흠 다섯 — 보조 글 줄 안 inline · 단추 실측 = 단 · 숨김 단추 · 놓기 칸 전체 누름 · 격자표 (PLAN 552-5)
- 보조 글: span · small 에 붙은 .ui-text = inline(줄 안 끊김) · p · div 는 한 줄 상자 그대로
- 단추: 위아래 여백 = 1em 기준 + 줄 높이 1 · 왼쪽 패널 양식(.ui-panel-form) 옛 규칙(글 12 · 줄 높이 normal)이 단을 덮지 않음 — B07 도면 단추 35 → 32
- 숨김: .ui-btn · .ui-icon-btn [hidden] = display none(공용 기본 꼴이 안 실린 곳에서도)
- 파일 놓기 칸: 칸 어디든 누름 · 초점 Enter · Space = 파일 고르기(옛 B03 동작) · 단추 누름은 칸으로 올라가 한 번만
- 표: 격자 꼴(grid · 세로선 · 칸 테두리 · 몸 안 이름 칸 th) · 합친 칸(colSpan · rowSpan · head) · columns 비우면 머리 줄 없음 · 기본 꼴 그대로
- 부품 모음 견본: 글 줄 안 보조 글 · 격자표(수량 산출표 꼴)
- 시험: test_552_5_part_fixes 새 · test_552_2_button 높이 식 기대 고침(1em · 줄 높이 1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:38:33 +09:00

116 lines
4.4 KiB
TypeScript

/* =============================================================================
* 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,
};
}