Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
169 lines
8.0 KiB
Python
169 lines
8.0 KiB
Python
"""246-2 · 129 — 공용 우클릭 메뉴 하위 메뉴(children) = 「글 ▸」 한 줄 + 오른쪽 옆 새 판.
|
|
|
|
- 올림 · 누름 · → = 옆 판(머리 줄 높이 · 메뉴 오른쪽) · 다른 줄 올림 · ← = 닫힘(← 는 머리로 초점)
|
|
- 화면 오른쪽 끝이면 메뉴 왼쪽 · 옆 판 고름 = 메뉴 닫고 동작 · 바깥 누름 · Esc = 모두 닫힘
|
|
- 옛 아코디언(같은 판 안 펼침 · ▾) 없음 · B09 「연결」 = children 그대로(옆 판으로 감)
|
|
"""
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[2]
|
|
IMPORTS = re.compile(r"^import[^;]*;\r?\n", re.MULTILINE)
|
|
|
|
DOM_JS = """
|
|
const docOn = {};
|
|
class El {
|
|
constructor(tag) { Object.assign(this, { tag, kids: [], parent: null, on: {}, style: {},
|
|
className: "", hidden: false, textContent: "", attrs: {} }); }
|
|
get classList() {
|
|
const el = this;
|
|
const list = () => el.className.split(" ").filter(Boolean);
|
|
return { add: (c) => { if (!list().includes(c)) el.className = [...list(), c].join(" "); },
|
|
remove: (c) => (el.className = list().filter((x) => x !== c).join(" ")),
|
|
contains: (c) => list().includes(c) };
|
|
}
|
|
get ownerDocument() { return { defaultView: globalThis.view }; }
|
|
get firstElementChild() { return this.kids[0] ?? null; }
|
|
get lastElementChild() { return this.kids.at(-1) ?? null; }
|
|
setAttribute(k, v) { this.attrs[k] = v; }
|
|
append(...xs) { for (const x of xs) { x.parent = this; this.kids.push(x); } }
|
|
replaceChildren() { this.kids = []; }
|
|
remove() { if (this.parent) this.parent.kids = this.parent.kids.filter((x) => x !== this); }
|
|
contains(x) { for (let at = x; at; at = at.parent) if (at === this) return true; return false; }
|
|
addEventListener(t, f) { (this.on[t] ??= []).push(f); }
|
|
fire(t, ev = {}) { for (const f of this.on[t] ?? []) f({ target: this, preventDefault() {},
|
|
stopPropagation() {}, ...ev }); }
|
|
focus() { doc.activeElement = this; }
|
|
all() { return this.kids.flatMap((k) => [k, ...k.all()]); }
|
|
match(sel) { return sel.startsWith(".") ? sel.slice(1).split(".").every((c) => this.classList.contains(c))
|
|
: this.tag === sel; }
|
|
querySelectorAll(sel) {
|
|
let found = [this];
|
|
for (const part of sel.split(" ")) found = found.flatMap((el) => el.all().filter((k) => k.match(part)));
|
|
return found;
|
|
}
|
|
querySelector(sel) { return this.querySelectorAll(sel)[0] ?? null; }
|
|
getBoundingClientRect() {
|
|
if (this.style.left !== undefined) {
|
|
const side = this.classList.contains("ui-ctx-menu__side");
|
|
const left = parseFloat(this.style.left), top = parseFloat(this.style.top);
|
|
const width = side ? 150 : 160;
|
|
const height = 8 + 30 * this.kids.filter((k) => k.tag === "button").length;
|
|
return { left, top, width, height, right: left + width, bottom: top + height };
|
|
}
|
|
const box = this.parent.getBoundingClientRect();
|
|
const top = box.top + 4 + 30 * this.parent.kids.filter((k) => k.tag === "button").indexOf(this);
|
|
return { left: box.left + 4, top, width: box.width - 8, height: 30, right: box.right - 4, bottom: top + 30 };
|
|
}
|
|
}
|
|
const doc = { activeElement: null, createElement: (t) => new El(t),
|
|
addEventListener: (t, f) => (docOn[t] ??= []).push(f) };
|
|
globalThis.document = doc;
|
|
globalThis.Node = El;
|
|
globalThis.view = { innerWidth: 1000, innerHeight: 800 };
|
|
globalThis.window = globalThis.view;
|
|
"""
|
|
|
|
SCENE_JS = """
|
|
const log = [];
|
|
const menu = createMapContextMenu("b09-estimation");
|
|
const items = [["A", () => log.push("A")],
|
|
{ label: "연결", children: [["x", () => log.push("x")], ["y", () => log.push("y")]] }, "-",
|
|
["B", () => log.push("B")]];
|
|
const el = menu.element;
|
|
const btns = () => el.kids.filter((k) => k.tag === "button");
|
|
const side = () => el.querySelector(".ui-ctx-menu__side");
|
|
const sideText = () => side()?.kids.map((k) => k.textContent) ?? null;
|
|
const at = () => [parseFloat(side().style.left), parseFloat(side().style.top), side().style.position];
|
|
const out = {};
|
|
menu.open(100, 100, items);
|
|
const head = btns()[1];
|
|
out.open = [el.kids.map((k) => k.textContent || k.className), sideText(), head.attrs["aria-haspopup"]];
|
|
head.fire("pointerenter");
|
|
out.hover = [sideText(), at(), head.classList.contains("is-open"), side().className];
|
|
out.firstRow = side().kids[0].getBoundingClientRect().top === head.getBoundingClientRect().top;
|
|
out.contains = menu.contains(side().kids[0]);
|
|
btns()[0].fire("pointerenter");
|
|
out.leave = [sideText(), head.classList.contains("is-open")];
|
|
head.fire("click");
|
|
out.click = [sideText(), doc.activeElement?.textContent];
|
|
side().fire("keydown", { key: "ArrowLeft" });
|
|
out.left = [sideText(), doc.activeElement === head];
|
|
head.fire("keydown", { key: "ArrowRight" });
|
|
out.right = [sideText(), doc.activeElement?.textContent];
|
|
side().kids[1].fire("click");
|
|
out.pick = [el.hidden, el.kids.length, [...log]];
|
|
// 화면 오른쪽 끝 — 메뉴는 안쪽으로 당김 · 옆 판 = 메뉴 왼쪽
|
|
view.innerWidth = 400;
|
|
menu.open(300, 100, items);
|
|
btns()[1].fire("pointerenter");
|
|
out.edge = [parseFloat(el.style.left), at()[0]];
|
|
// 아래 끝 — 옆 판 올림
|
|
view.innerWidth = 1000; view.innerHeight = 100;
|
|
menu.open(100, 10, items);
|
|
btns()[1].fire("pointerenter");
|
|
out.bottom = at()[1];
|
|
view.innerHeight = 800;
|
|
// Esc · 바깥 누름 = 모두 닫힘
|
|
menu.open(100, 100, items);
|
|
btns()[1].fire("pointerenter");
|
|
for (const f of docOn.keydown) f({ key: "Escape", stopPropagation() {}, preventDefault() {} });
|
|
out.esc = [el.hidden, side()];
|
|
menu.open(100, 100, items);
|
|
btns()[1].fire("pointerenter");
|
|
const inside = side().kids[0];
|
|
for (const f of docOn.pointerdown) f({ target: inside });
|
|
out.insideDown = el.hidden;
|
|
for (const f of docOn.pointerdown) f({ target: new El("div") });
|
|
out.outsideDown = el.hidden;
|
|
console.log(JSON.stringify(out));
|
|
"""
|
|
|
|
|
|
def _node(source: str) -> str:
|
|
result = subprocess.run(
|
|
["node", "--experimental-strip-types", "--no-warnings",
|
|
"--input-type=module-typescript"],
|
|
input=source, capture_output=True, text=True, encoding="utf-8", timeout=60,
|
|
) # fmt: skip
|
|
assert result.returncode == 0, result.stderr
|
|
return result.stdout
|
|
|
|
|
|
def _src() -> str:
|
|
return (ROOT / "ui_template" / "ui_template_context_menu.ts").read_text("utf-8")
|
|
|
|
|
|
def test_옆_판_열기_닫기_자리():
|
|
out = json.loads(_node(DOM_JS + IMPORTS.sub("", _src()) + SCENE_JS))
|
|
# 「연결 ▸」 한 줄 · 처음엔 옆 판 없음(같은 판 안 펼침 없음)
|
|
assert out["open"] == [["A", "연결 ▸", "ui-ctx-menu__sep", "B"], None, "menu"]
|
|
# 올림 = 옆 판 다섯 자리 — 메뉴 오른쪽(100 + 160) · 첫 줄 = 머리 줄 높이(134 - 4) · fixed
|
|
names = "b09-estimation__context-menu-sub ui-ctx-menu ui-ctx-menu__side"
|
|
assert out["hover"] == [["x", "y"], [260, 130, "fixed"], True, names]
|
|
assert out["firstRow"] is True and out["contains"] is True
|
|
assert out["leave"] == [None, False] # 다른 줄 올림 = 닫힘
|
|
assert out["click"] == [["x", "y"], "x"] # 누름 = 열고 첫 줄 초점
|
|
assert out["left"] == [None, True] # ← = 닫고 머리 줄로
|
|
assert out["right"] == [["x", "y"], "x"] # → = 열기
|
|
assert out["pick"] == [True, 0, ["y"]] # 옆 판 고름 = 메뉴 닫고 동작
|
|
# 오른쪽 끝 — 메뉴 = 400 - 4 - 160 · 옆 판 = 메뉴 왼쪽(236 - 150)
|
|
assert out["edge"] == [236, 86]
|
|
# 아래 끝(높이 100) — 옆 판(68) 바닥 = 100 - 4
|
|
assert out["bottom"] == 100 - 4 - 68
|
|
assert out["esc"] == [True, None]
|
|
assert out["insideDown"] is False and out["outsideDown"] is True
|
|
|
|
|
|
def test_옛_아코디언_없음_공용_꼴():
|
|
src = _src()
|
|
assert "ui-ctx-menu__sub" not in src and "▾" not in src and "is-sub" not in src
|
|
css = (ROOT / "ui_template" / "ui_template_context_menu.css").read_text("utf-8")
|
|
assert ".ui-ctx-menu__sub" not in css and ".ui-ctx-menu__item.is-open" in css
|
|
# B09 연결 = children 꼴 그대로(공용 메뉴가 옆 판으로)
|
|
edit = (ROOT / "B09_Estimation" / "B09_Estimation_UI_Tabs_Bill_Edit.ts").read_text("utf-8")
|
|
assert "parts[1].push(...picks, ...more);" in edit # 251-6 단가연결 = 모달 하나(하위 메뉴 없음)
|