diff --git a/E01_StabRegister/E01_StabRegister_UI_Missing.css b/E01_StabRegister/E01_StabRegister_UI_Missing.css deleted file mode 100644 index 0e54a15cf..000000000 --- a/E01_StabRegister/E01_StabRegister_UI_Missing.css +++ /dev/null @@ -1,21 +0,0 @@ -/* 빠진 횡단 띠 — E02 · E03 화면 위(E01_StabRegister_UI_Missing.ts) */ -.e-missing { - margin: 0 0 8px; - padding: 6px 10px; - border: 1px solid var(--color-warning); - border-left-width: 4px; - border-radius: var(--radius-sm); - background: var(--color-surface-raised); - color: var(--color-text); - font-size: 13px; -} - -.e-missing > summary { - cursor: pointer; - font-weight: 600; -} - -.e-missing > p { - margin: 6px 0 0; - color: var(--color-text-secondary); -} diff --git a/E01_StabRegister/E01_StabRegister_UI_Missing.ts b/E01_StabRegister/E01_StabRegister_UI_Missing.ts index 252d0ccc9..eadd7b4bc 100644 --- a/E01_StabRegister/E01_StabRegister_UI_Missing.ts +++ b/E01_StabRegister/E01_StabRegister_UI_Missing.ts @@ -1,10 +1,11 @@ /* ============================================================================= * E01_StabRegister_UI_Missing.ts * 빠진 횡단 띠(요구 351 · PLAN 532-5) — E02 · E03 화면 위 「횡단 없는 측점 N 개 — 해석에서 빠짐」. - * 목록 = 서버 `missing_stations`(E01 복사본에 횡단 파일이 없는 측점 누가거리) · 누르면 펼침. + * 목록 = 서버 `missing_stations`(E01 복사본에 횡단 파일이 없는 측점 누가거리) · 「자세히」로 펼침. + * 모양 = 띠 알림 부품(경고). * ========================================================================== */ -import "./E01_StabRegister_UI_Missing.css"; +import { createBanner } from "@ui/ui_template_banner"; import { el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; @@ -12,13 +13,13 @@ import { t } from "@ui/ui_template_locale"; export function buildMissingBanner(missing: number[] | undefined): HTMLElement { const list = missing ?? []; if (!list.length) return el("div"); - const box = el("details", { - className: "e-missing", - attrs: { "data-role": "missing-stations", "data-count": String(list.length) }, + const box = createBanner({ + tone: "warning", + text: t("E_Missing_Head").replace("{n}", String(list.length)), + details: el("p", { text: list.map((m) => `${Math.round(m * 100) / 100} m`).join(" · ") }), }); - box.append( - el("summary", { text: t("E_Missing_Head").replace("{n}", String(list.length)) }), - el("p", { text: list.map((m) => `${Math.round(m * 100) / 100} m`).join(" · ") }), - ); + box.classList.add("e-missing"); + box.dataset.role = "missing-stations"; + box.dataset.count = String(list.length); return box; } diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts index b45bb974f..a483e2d71 100644 --- a/E02_StabGround/E02_StabGround_UI_Page.ts +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -18,10 +18,11 @@ import { showToast, } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { createText } from "@ui/ui_template_text"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; -import { buildCards, type E02View } from "./E02_StabGround_UI_Tables"; +import { buildCards, hook, type E02View } from "./E02_StabGround_UI_Tables"; import { checkGround, type CheckError, @@ -74,7 +75,7 @@ async function request(path: string, init?: RequestInit): Promise {}; @@ -104,22 +104,28 @@ export function renderE02StabGround(root: HTMLElement): void { if (view && !view.editable) lines.push(t("E02_ReadOnly")); if (dirty) lines.push(t("E02_Unsaved")); if (errors.length) lines.push(`${t("E02_Invalid")} ${errors.length}`); - status.textContent = lines.join("\n"); + status.replaceChildren( + ...(lines.length + ? [createText(lines.join("\n"), errors.length > 0 ? "error" : "warning")] + : []), + ); status.classList.toggle("is-error", errors.length > 0); }; - /** 틀린 칸 표시 — 칸마다 is-invalid + data-error(마우스 글) */ + /** 틀린 칸 표시 — 칸마다 is-error(부품 오류 상태) + data-error(마우스 글) */ const mark = (): CheckError[] => { if (!view) return []; const errors = checkGround(view.ground, view.defaults.limits); - for (const node of main.querySelectorAll(".is-invalid")) { - node.classList.remove("is-invalid"); + for (const node of main.querySelectorAll("[data-error]")) { + node.classList.remove("is-error"); + node.removeAttribute("aria-invalid"); delete node.dataset.error; } for (const error of errors) { const node = main.querySelector(`[data-path="${error.path}"]`); if (!node) continue; - node.classList.add("is-invalid"); + node.classList.add("is-error"); + node.setAttribute("aria-invalid", "true"); node.dataset.error = errorText(error, view.defaults); } paintStatus(errors); @@ -242,10 +248,10 @@ export function renderE02StabGround(root: HTMLElement): void { adopt(body, true); } catch (error) { main.replaceChildren( - el("p", { - className: "e02-empty", - text: error instanceof Error ? error.message : t("Common_Status_Error"), - }), + hook( + createText(error instanceof Error ? error.message : t("Common_Status_Error")), + "e02-empty", + ), ); } })(); diff --git a/E02_StabGround/E02_StabGround_UI_Style.css b/E02_StabGround/E02_StabGround_UI_Style.css index 88b33d304..3e715fb20 100644 --- a/E02_StabGround/E02_StabGround_UI_Style.css +++ b/E02_StabGround/E02_StabGround_UI_Style.css @@ -10,6 +10,10 @@ box-sizing: border-box; } +.e02-page > .ui-banner { + margin-bottom: var(--spacing-8); +} + .e02-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(560px, 100%), 1fr)); @@ -17,33 +21,19 @@ align-items: start; } +/* 카드 모양은 카드 부품(머리 카드) — 여기는 자리만 */ .e02-card { - display: flex; - flex-direction: column; - gap: var(--spacing-8); min-width: 0; - padding: var(--spacing-12) var(--spacing-16); - border-radius: var(--radius-sm); - background: var(--color-surface-raised); } -.e02-card__head { - display: flex; - align-items: center; - justify-content: space-between; +.e02-card .ui-card__body { gap: var(--spacing-8); } -.e02-card__title, .e02-card__sub { - margin: 0; + margin: var(--spacing-8) 0 0; color: var(--color-text); - font-size: var(--text-body); -} - -.e02-card__sub { font-size: var(--text-body-sm); - margin-top: var(--spacing-8); } .e02-card__actions { @@ -103,11 +93,6 @@ min-width: 96px; } -.e02-input.is-invalid { - border-color: var(--color-danger); - box-shadow: 0 0 0 1px var(--color-danger); -} - .e02-rowhead { white-space: nowrap; color: var(--color-text-secondary); @@ -135,22 +120,9 @@ color: var(--color-text-secondary); } -.e02-hint, -.e02-empty { - margin: 0; - font-size: var(--text-caption); - color: var(--color-text-secondary); -} - .e02-status { margin: var(--spacing-8) 0 0; white-space: pre-line; - font-size: var(--text-body-sm); - color: var(--color-warning); -} - -.e02-status.is-error { - color: var(--color-danger); } .e02-table .ui-btn { diff --git a/E02_StabGround/E02_StabGround_UI_Tables.ts b/E02_StabGround/E02_StabGround_UI_Tables.ts index 5b38bd0b1..7f9967e9e 100644 --- a/E02_StabGround/E02_StabGround_UI_Tables.ts +++ b/E02_StabGround/E02_StabGround_UI_Tables.ts @@ -6,8 +6,16 @@ * 값 바꿈 = view.change()(캐시 · 검사) · 줄 더함 · 지움 · [기본값] = view.rebuild(). * ========================================================================== */ -import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { createCard } from "@ui/ui_template_card"; import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createText } from "@ui/ui_template_text"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { @@ -32,6 +40,11 @@ export interface E02View { } const L = (key: string): string => t(key as LocaleKey); +/** 부품이 낸 요소에 촬영 장면 · 시험이 잡는 이름만 얹음(CSS 없음) */ +export const hook = (node: T, name: string): T => ( + node.classList.add(name), + node +); export const clone = (value: T): T => JSON.parse(JSON.stringify(value)) as T; const FIELD_LABEL: Record<(typeof MATERIAL_FIELDS)[number], LocaleKey> = { gamma_t: "E02_Col_GammaT", @@ -48,10 +61,8 @@ export function numInput( set: (v: number | null) => void, meta: { src?: string; def?: number | null; nullable?: boolean } = {}, ): HTMLInputElement { - const input = el("input", { - className: "ui-input ui-input--sm e02-input e02-input--num", - }) as HTMLInputElement; - input.type = "number"; + const input = createInputField({ type: "number", compact: true }).input; + input.classList.add("e02-input", "e02-input--num"); input.step = "any"; input.value = value === null || value === undefined ? "" : String(value); input.dataset.path = path; @@ -75,10 +86,8 @@ export function selectInput( set: (v: string) => void, meta: { src?: string; def?: string } = {}, ): HTMLSelectElement { - const select = el("select", { - className: "ui-select ui-select--sm e02-input", - }) as HTMLSelectElement; - for (const option of options) select.append(new Option(option.text, option.value)); + const select = createSelectField({ options, compact: true }).select; + select.classList.add("e02-input"); select.value = value ?? ""; select.dataset.path = path; if (meta.src) select.dataset.src = meta.src; @@ -95,10 +104,9 @@ export function card( title: LocaleKey, ...body: HTMLElement[] ): { section: HTMLElement; head: HTMLElement } { - const head = el("div", { className: "e02-card__head" }); - head.append(el("h3", { className: "e02-card__title", text: t(title) })); - const section = el("section", { className: "e02-card ui-sidebar-section" }); - section.append(head, ...body); + const head = el("div", { className: "e02-card__actions" }); + const section = createCard({ title: t(title), actions: [head], body, pad: 16 }); + section.classList.add("e02-card", "ui-sidebar-section"); return { section, head }; } @@ -108,9 +116,7 @@ export function headButtons( ...buttons: HTMLButtonElement[] ): void { if (!view.editable) return; - const row = el("div", { className: "e02-card__actions" }); - row.append(...buttons); - head.append(row); + head.append(...buttons); } export function table(headers: string[], rows: HTMLElement[][]): HTMLTableElement { @@ -141,9 +147,8 @@ function materialsCard(view: E02View): HTMLElement { Object.values(g.label_map).includes(id) || g.zones.some((z) => z.soil === id || z.rock === id); const rows = g.materials.map((m, i) => { const base = `materials.${i}`; - const name = el("input", { - className: "ui-input ui-input--sm e02-input e02-input--name", - }) as HTMLInputElement; + const name = createInputField({ compact: true }).input; + name.classList.add("e02-input", "e02-input--name"); name.value = m.name; name.dataset.path = `${base}.name`; name.disabled = !view.editable; @@ -293,15 +298,13 @@ function zonesCard(view: E02View): { section: HTMLElement; refresh: () => void } ], rows, ) - : el("p", { className: "e02-empty", text: t("E02_Zone_Empty") }); + : hook(createText(t("E02_Zone_Empty")), "e02-empty"); const stationsHost = el("div", { className: "e02-stations" }); const refresh = (): void => { stationsHost.replaceChildren(); if (!g.stations.length) - return void stationsHost.append( - el("p", { className: "e02-empty", text: t("E02_Stations_Empty") }), - ); + return void stationsHost.append(hook(createText(t("E02_Stations_Empty")), "e02-empty")); const names = new Map(g.materials.map((m) => [m.id, m.name])); const interval = inferStationInterval(g.stations); const fmt = (v: number | null): string => (v === null ? "-" : v.toFixed(2)); @@ -332,7 +335,7 @@ function zonesCard(view: E02View): { section: HTMLElement; refresh: () => void } const { section, head } = card( "E02_Card_Zones", - el("p", { className: "e02-hint", text: t("E02_Zone_Hint") }), + hook(createText(t("E02_Zone_Hint")), "e02-hint"), zoneBody, el("h4", { className: "e02-card__sub", text: t("E02_Card_Stations") }), stationsHost, diff --git a/E02_StabGround/E02_StabGround_UI_Water.ts b/E02_StabGround/E02_StabGround_UI_Water.ts index e613ae911..08d1fbba6 100644 --- a/E02_StabGround/E02_StabGround_UI_Water.ts +++ b/E02_StabGround/E02_StabGround_UI_Water.ts @@ -6,12 +6,14 @@ * ========================================================================== */ import { createButton, el } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { WATER_KEYS, WATER_RULES, WET_MODES, type WetMode } from "./logic/E02_StabGround_Check"; import { card, clone, headButtons, + hook, numInput, selectInput, table, @@ -40,7 +42,7 @@ function removeButton(view: E02View, onClick: () => void): HTMLButtonElement { /** 사용자 수위선 구간 표 — 줄마다 시작 · 끝 · 점(offset · 깊이) · [점 추가] · [지움] */ function linesBody(view: E02View): HTMLElement { const water = view.ground.groundwater; - if (!water.lines.length) return el("p", { className: "e02-empty", text: t("E02_Line_Empty") }); + if (!water.lines.length) return hook(createText(t("E02_Line_Empty")), "e02-empty"); const rows = water.lines.map((line, i) => { const base = `groundwater.lines.${i}`; const points = el("div", { className: "e02-points" }); @@ -136,11 +138,11 @@ export function waterCard(view: E02View): HTMLElement { ), ); if (water.wet_mode === "line") - body.push(el("p", { className: "e02-hint", text: t("E02_Line_Hint") }), linesBody(view)); + body.push(hook(createText(t("E02_Line_Hint")), "e02-hint"), linesBody(view)); const { section, head } = card( "E02_Card_Water", - el("p", { className: "e02-hint", text: t("E02_Water_Hint") }), + hook(createText(t("E02_Water_Hint")), "e02-hint"), grid, ...body, ); diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 7cc5d81d6..9fc87bb68 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -57,3 +57,23 @@ def test_d04_parts(): assert "input:checked" in ts # [확정] 이 고른 연도를 읽는 길 assert "border:" not in css.split(".d04-confirm__input")[1].split("}")[0] assert ".d04-confirm__dirty" not in css + + +def test_e01_missing_is_banner_part(): + ts = _src("E01_StabRegister/E01_StabRegister_UI_Missing.ts") + assert "createBanner" in ts and '"details"' not in ts + assert 'dataset.role = "missing-stations"' in ts # 시험 · 촬영이 잡음 + assert not (ROOT / "E01_StabRegister/E01_StabRegister_UI_Missing.css").exists() + + +def test_e02_parts(): + tables = _src("E02_StabGround/E02_StabGround_UI_Tables.ts") + page = _src("E02_StabGround/E02_StabGround_UI_Page.ts") + css = _src("E02_StabGround/E02_StabGround_UI_Style.css") + assert 'el("input"' not in tables and 'el("select"' not in tables + assert "createInputField(" in tables and "createSelectField(" in tables + assert "createCard({ title" in tables + assert "is-invalid" not in page + css and "is-error" in page + for dead in (".e02-card__head", ".e02-card__title", ".e02-hint", ".e02-empty"): + assert dead not in css, dead + _no_hex("E02_StabGround/E02_StabGround_UI_Style.css")