From 2a914e11ebc412428602b79c4ab8485cff1158e0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 5 Oct 2026 19:59:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EA=B3=B5=EC=9A=A9=20=EC=B0=BE?= =?UTF-8?q?=EA=B8=B0=20=EB=AA=A9=EB=A1=9D=20=EC=96=91=EC=8B=9D=20=C2=B7=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=EC=B0=BD=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=E2=80=94=20=EA=B5=AC=EB=B6=84=20=C2=B7=20?= =?UTF-8?q?=EC=83=81=EC=84=B8=EA=B5=AC=EB=B6=84=202=EC=97=B4=20=C2=B7=20?= =?UTF-8?q?=EC=9D=B4=EB=A6=84=20=EC=99=BC=EC=AA=BD=20=EB=A7=9E=EC=B6=A4=20?= =?UTF-8?q?(51-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template_find_list(.ts · .css) 새로 둠 — 거름 칸 2열 격자 · 찾기 칸 · 알림 줄 · 한 줄 결과 목록(꼬리표 · 이름 · 흐린 보조 글 · 단위) · 고름 하이라이트 · ↑ ↓ Enter - M02 로직 고르기 창(Labor_Pick)이 이 양식을 씀 — 구분 칸 · 층 단추 · data-pick 표지 그대로 - 확인 시나리오 resources/tester/ui_checks/51-9_고르기_찾기양식.js Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../M02_MasterTemplete_Labor_Pick.ts | 109 +++++++----------- .../tester/ui_checks/51-9_고르기_찾기양식.js | 90 +++++++++++++++ ui_template/ui_template_find_list.css | 73 ++++++++++++ ui_template/ui_template_find_list.ts | 103 +++++++++++++++++ 4 files changed, 305 insertions(+), 70 deletions(-) create mode 100644 resources/tester/ui_checks/51-9_고르기_찾기양식.js create mode 100644 ui_template/ui_template_find_list.css create mode 100644 ui_template/ui_template_find_list.ts diff --git a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts index 5477edeb0..44756fbb0 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Labor_Pick.ts @@ -5,13 +5,8 @@ * 프로젝트 · 카드엔 서고 줄 없음 * ========================================================================== */ -import { - createButton, - createInputField, - createSelectField, - el, - showToast, -} from "@ui/ui_template_elements"; +import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; +import { createFindList } from "@ui/ui_template_find_list"; import { t as L } from "@ui/ui_template_locale"; import type { LogicSummary, SubBrief } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; @@ -65,9 +60,6 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra const sub = createSelectField({ options: opts([]), compact: true, label: tc("Sub") }); const detail = createSelectField({ options: opts([]), compact: true, label: tc("Detail") }); detail.select.disabled = true; - const searchField = createInputField({ type: "search", placeholder: tc("Pick_Search") }); - const list = el("div", { className: "m01-logic__pick-list" }); - const hint = el("p", { className: "m01-logic__muted" }); const close = (): void => backdrop.remove(); /* 한 목록 — 구분(단가산출 · 단가승격 · 일위조합) 칸 · 층 단추는 서고 줄(승격 · 조합)의 층 */ @@ -77,31 +69,15 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra let libRows: { kind: "Kind_Promote" | "Kind_Combo"; row: LibraryRow }[] = []; let loading = false; let seq = 0; - const pickRow = ( - key: string, - kind: string, - title: string, - sub2: string, - unit: string, - ): HTMLElement => - el("button", { - className: "m01-logic__pick-row m01-logic__pick-row--button", - attrs: { type: "button", "data-pick": key }, - children: [ - el("span", { className: "m01-logic__muted", attrs: { "data-pick-kind": "" }, text: kind }), - el("div", { - className: "m01-logic__stack", - children: [ - el("strong", { text: title }), - el("span", { className: "m01-logic__muted", text: sub2 }), - ], - }), - el("span", { text: unit }), - ], - }); + /* 공용 찾기 목록 — 구분 · 상세구분 2열 · 이름 왼쪽 맞춤 · ↑ ↓ Enter */ + const finder = createFindList({ + placeholder: tc("Pick_Search"), + filters: lib?.only ? [] : [sub.root, detail.root], + extra: [layerBar], + }); const find = (): void => { - const q = searchField.input.value.trim().toLowerCase(); + const q = finder.search.value.trim().toLowerCase(); const found = all.filter( (x) => (!sub.select.value || x.구분 === sub.select.value) && @@ -113,38 +89,38 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra : libRows.filter((x) => !q || `${x.row.이름} ${x.row.제목 ?? ""}`.toLowerCase().includes(q)); const total = all.length + libRows.length; const shown = found.length + libFound.length; - hint.textContent = loading ? tc("Loading") : shown ? `${shown} / ${total}` : tc("Pick_None"); - list.replaceChildren( - ...found.slice(0, 100).map((x) => { - const row = pickRow( - x.키, - tc("Kind_Logic"), - x.이름, - `${x.키} · ${x.구분} ${x.상세구분}`.trim(), - `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`, - ); - row.addEventListener("click", () => { + finder.hint.textContent = loading + ? tc("Loading") + : shown + ? `${shown} / ${total}` + : tc("Pick_None"); + finder.setRows([ + ...found.slice(0, 100).map((x) => ({ + key: x.키, + tag: tc("Kind_Logic"), + name: x.이름, + sub: `${x.키} · ${x.구분} ${x.상세구분}`.trim(), + unit: `${x.결과단위}${x.blocked ? ` · ${tc("Pick_Blocked")}` : ""}`, + onPick: () => { close(); done(x); - }); - return row; - }), + }, + })), ...libFound.map((x) => { const at = scope as Scope; - const row = pickRow( - x.row.이름, - tc(x.kind), - x.row.제목 || x.row.이름, - x.row.이름, - x.row.단위 ?? "", - ); - row.addEventListener("click", () => { - close(); - lib?.pick({ ...at, 키: x.row.이름 }, x.row.제목 || x.row.이름); - }); - return row; + return { + key: x.row.이름, + tag: tc(x.kind), + name: x.row.제목 || x.row.이름, + sub: x.row.이름, + unit: x.row.단위 ?? "", + onPick: () => { + close(); + lib?.pick({ ...at, 키: x.row.이름 }, x.row.제목 || x.row.이름); + }, + }; }), - ); + ]); }; const loadLib = async (): Promise => { if (!lib || !scope) return; @@ -191,12 +167,8 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra find(); }); detail.select.addEventListener("change", find); - searchField.input.addEventListener("input", find); + finder.search.addEventListener("input", find); - const filters = el("div", { - className: "m01-logic__filters", - children: [...(lib?.only ? [] : [sub.root, detail.root]), searchField.root], - }); layerBar.style.display = "none"; if (lib) void upper(lib.layer, lib.owner).then((got) => { @@ -219,17 +191,14 @@ export function openLogicPicker(done: (logic: LogicSummary) => void, lib?: Libra createButton({ label: tc("Pick_Close"), variant: "ghost", onClick: close }), ], }), - filters, - layerBar, - hint, - list, + finder.root, ], }); const backdrop = el("div", { className: "m01-logic__backdrop", children: [dialog] }); backdrop.addEventListener("click", (ev) => ev.target === backdrop && close()); backdrop.addEventListener("keydown", (ev) => ev.key === "Escape" && close()); document.body.append(backdrop); - searchField.input.focus(); + finder.search.focus(); if (lib?.only) return; void Promise.all([fetchLogicSubs(), fetchLogics()]) diff --git a/resources/tester/ui_checks/51-9_고르기_찾기양식.js b/resources/tester/ui_checks/51-9_고르기_찾기양식.js new file mode 100644 index 000000000..5a0c5c23b --- /dev/null +++ b/resources/tester/ui_checks/51-9_고르기_찾기양식.js @@ -0,0 +1,90 @@ +/* 51-9 — [로직 추가] 고르기 창이 공용 찾기 양식(ui_template_find_list)을 씀 + * 시작 화면: M02 마스터 템플릿 · 마스터 층(#/m02-master-template?open=구조물조합&root=master) · 시스템 관리자 · item1 있음 + * 부르는 법: orca eval --expression "$(tr ' +' ' ' < 이 파일)" (async 식 · JSON 글을 돌려줌) + * 하는 일: item1 → 일위대가 [수정] → [로직 추가] → 칸 배치 · 줄 맞춤 · ↓ Enter 점검 → 창 닫음(저장 안 함) + * 기대 수치: + * - template = true · filters = 2 · sameLine = true(구분 · 상세구분 top 같음 · left 다름) + * - nameAlign = "left" · nameLeftGap ≤ 꼬리표 폭 + 16 · rowHeight ≤ 40 · oneLine = true + * - active0 = 0 · activeAfterDown = 1 · enterPicked = true(Enter 로 창이 닫힘) · undone = true + */ +(async () => { + const w = (ms) => new Promise((r) => setTimeout(r, ms)); + const until = async (fn, ms = 8000) => { + for (let t = 0; t < ms; t += 150) { + const v = fn(); + if (v) return v; + await w(150); + } + return null; + }; + const out = {}; + const node = await until(() => document.querySelector('[data-node="i|item1"]')); + if (!node) return JSON.stringify({ error: "item1 없음" }); + node.click(); + const card = await until(() => + [...document.querySelectorAll(".m02-struct__card")].find((c) => + c.querySelector("table.m02-labrow tbody tr[data-labor-logic]"), + ), + ); + if (!card) return JSON.stringify({ error: "일위대가 카드 없음" }); + [...card.querySelectorAll(".m02-struct__card-tools button")] + .find((b) => b.innerText.trim() === "수정") + ?.click(); + const table = () => + [...document.querySelectorAll("table.m02-labrow")].find( + (t) => !t.closest(".m02-struct__card") && t.querySelector("tr[data-labor-logic]"), + ); + await until(() => table() && !/…/.test(table().querySelector("tbody").innerText), 12000); + const host = table().closest("[data-labor]"); + const before = table().querySelectorAll("tbody tr[data-labor-logic]").length; + const openPick = async () => { + [...host.querySelectorAll(".m01-logic__section-head button")] + .find((b) => b.innerText.trim() === "로직 추가") + .click(); + const dlg = await until(() => document.querySelector(".m01-logic__pick")); + await until(() => dlg.querySelectorAll(".ui-find__row").length > 1, 20000); + return dlg; + }; + const dlg = await openPick(); + out.template = !!dlg.querySelector(".ui-find .ui-find__filters") && !!dlg.querySelector(".ui-find__list"); + const sels = [...dlg.querySelectorAll(".ui-find__filters > *")]; + out.filters = sels.length; + const [a, b] = sels.map((x) => x.getBoundingClientRect()); + out.sameLine = Math.abs(a.top - b.top) < 2 && b.left > a.right - 1; + const row = dlg.querySelector(".ui-find__row"); + const name = row.querySelector(".ui-find__name"); + const tag = row.querySelector(".ui-find__tag"); + out.nameAlign = getComputedStyle(name).textAlign; + out.rowAlign = getComputedStyle(row).textAlign; + out.nameLeftGap = Math.round(name.getBoundingClientRect().left - row.getBoundingClientRect().left); + out.tagWidth = Math.round(tag.getBoundingClientRect().width); + out.rowHeight = Math.round(row.getBoundingClientRect().height); + out.oneLine = getComputedStyle(row).whiteSpace === "nowrap"; + const rows = () => [...dlg.querySelectorAll(".ui-find__row")]; + const active = () => rows().findIndex((r) => r.classList.contains("is-active")); + out.active0 = active(); + const search = dlg.querySelector('input[type="search"]'); + search.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true })); + out.activeAfterDown = active(); + out.rows = rows().length; + /* Enter = 고름 — 창이 닫히고 표에 붙은 줄은 바로 지움 */ + const pickedKey = rows()[active()].dataset.pick; + search.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + out.enterPicked = !!(await until(() => !document.querySelector(".m01-logic__pick") || null)); + out.pickedKey = pickedKey; + await until(() => table().querySelectorAll("tbody tr[data-labor-logic]").length > before, 8000); + const added = [...table().querySelectorAll("tbody tr[data-labor-logic]")].slice(before); + for (const tr of added) { + [...tr.querySelectorAll("button")].find((x) => x.innerText.trim() === "삭제")?.click(); + await w(600); + } + out.undone = table().querySelectorAll("tbody tr[data-labor-logic]").length === before; + document.querySelector('[data-node="i|item1"]')?.click(); + await w(800); + const ask = [...document.querySelectorAll(".ui-modal button, dialog button")].find((x) => + /확인|버림|떠나기/.test(x.innerText), + ); + ask?.click(); + return JSON.stringify(out); +})(); diff --git a/ui_template/ui_template_find_list.css b/ui_template/ui_template_find_list.css new file mode 100644 index 000000000..04dbaf7e0 --- /dev/null +++ b/ui_template/ui_template_find_list.css @@ -0,0 +1,73 @@ +/* 공용 찾기 목록(ui_template_find_list.ts) — 거름 2열 · 결과 한 줄 · 이름 왼쪽 맞춤 */ +.ui-find { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-height: 0; +} + +.ui-find__filters { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--spacing-8); +} + +.ui-find__hint { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-find__list { + display: flex; + flex-direction: column; + min-height: 0; + overflow: auto; +} + +.ui-find__row { + display: flex; + align-items: baseline; + gap: var(--spacing-8); + width: 100%; + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-bottom: 1px solid var(--color-border); + background: none; + color: var(--color-text-body); + font: inherit; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.ui-find__row.is-active { + background: var(--color-mist-violet); + color: var(--color-accent); +} + +.ui-find__tag, +.ui-find__sub, +.ui-find__unit { + flex: none; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-find__tag { + min-width: 4.5em; +} + +.ui-find__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + text-align: left; +} + +.ui-find__sub { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/ui_template/ui_template_find_list.ts b/ui_template/ui_template_find_list.ts new file mode 100644 index 000000000..f4fcab62f --- /dev/null +++ b/ui_template/ui_template_find_list.ts @@ -0,0 +1,103 @@ +/* ============================================================================= + * ui_template_find_list.ts + * 공용 찾기 목록 — 걸러내기 고르기 칸(2열 격자) · 찾기 칸 · 알림 줄 · 결과 목록(이름 왼쪽 맞춤 · 한 줄). + * 키보드 — 찾기 칸에서 ↑ ↓ 로 옮기고 Enter 로 고름. 창 겉틀은 부르는 쪽 몫. + * + * const find = createFindList({ placeholder, filters: [sub.root, detail.root] }); + * find.setRows([{ key, tag, name, sub, unit, onPick }]); + * ========================================================================== */ + +import "./ui_template_find_list.css"; +import { createInputField, el } from "./ui_template_elements"; + +export interface FindRow { + /** 줄 표지(`data-pick`) */ + key: string; + name: string; + /** 앞 흐린 꼬리표(종류 등) */ + tag?: string; + /** 이름 뒤 흐린 보조 글(키 · 구분 등) */ + sub?: string; + /** 끝 흐린 글(단위 등) */ + unit?: string; + onPick: () => void; +} + +export interface FindList { + root: HTMLElement; + search: HTMLInputElement; + hint: HTMLElement; + setRows: (rows: FindRow[]) => void; +} + +export function createFindList(opts: { + placeholder: string; + filters?: HTMLElement[]; + /** 찾기 칸과 알림 줄 사이(층 단추 등) */ + extra?: HTMLElement[]; +}): FindList { + const field = createInputField({ type: "search", placeholder: opts.placeholder }); + const hint = el("p", { className: "ui-find__hint" }); + const list = el("div", { className: "ui-find__list", attrs: { role: "listbox" } }); + let rows: FindRow[] = []; + let at = -1; + + const mark = (i: number): void => { + list.children[at]?.classList.remove("is-active"); + at = i; + const row = list.children[at]; + row?.classList.add("is-active"); + row?.scrollIntoView({ block: "nearest" }); + }; + field.input.addEventListener("keydown", (ev) => { + if (ev.key === "ArrowDown" || ev.key === "ArrowUp") { + ev.preventDefault(); + if (!rows.length) return; + const step = ev.key === "ArrowDown" ? 1 : -1; + mark((at + step + rows.length) % rows.length); + } else if (ev.key === "Enter" && rows[at]) { + ev.preventDefault(); + rows[at].onPick(); + } + }); + + const setRows = (next: FindRow[]): void => { + rows = next; + at = -1; + list.replaceChildren( + ...rows.map((r, i) => { + const row = el("button", { + className: "ui-find__row", + attrs: { type: "button", role: "option", "data-pick": r.key, title: r.name }, + children: [ + ...(r.tag + ? [ + el("span", { + className: "ui-find__tag", + attrs: { "data-pick-kind": "" }, + text: r.tag, + }), + ] + : []), + el("span", { className: "ui-find__name", text: r.name }), + el("span", { className: "ui-find__sub", text: r.sub ?? "" }), + el("span", { className: "ui-find__unit", text: r.unit ?? "" }), + ], + }); + row.addEventListener("click", r.onPick); + row.addEventListener("mousemove", () => at !== i && mark(i)); + return row; + }), + ); + if (rows.length) mark(0); + }; + + const filters = opts.filters?.length + ? [el("div", { className: "ui-find__filters", children: opts.filters })] + : []; + const root = el("div", { + className: "ui-find", + children: [...filters, field.root, ...(opts.extra ?? []), hint, list], + }); + return { root, search: field.input, hint, setRows }; +}