diff --git a/A00_Common/b_page_tabs.ts b/A00_Common/b_page_tabs.ts new file mode 100644 index 000000000..179ac8c5c --- /dev/null +++ b/A00_Common/b_page_tabs.ts @@ -0,0 +1,46 @@ +/* ============================================================================= + * b_page_tabs.ts + * 페이지 탭 줄 — 부품 「탭」(`ui_template_tabs`)을 이미 있는 줄 요소 안에 채움(553-3 · B08 · B09 · ☰ [설정]). + * + * 쓰기 = `fillTabs(줄, { tabs, value, onChange, kind: "pill", data: "b08Tab" })` — 칸마다 `data-` = 탭 열쇠 + * 줄 요소는 페이지가 정함(클래스 · 자리) · 칸 모양 · 크기 · 고름 표시는 부품 몫 · 누른 칸이 지금 칸이면 안 부름 + * 탭 이름이 바뀌면 다시 부르면 됨(줄을 통째로 다시 채움) + * ========================================================================== */ + +import type { UiSize } from "@ui/ui_template_button"; +import { createTabs, type TabItem } from "@ui/ui_template_tabs"; + +export interface PageTabsOptions { + tabs: TabItem[]; + value: string; + onChange: (key: string) => void; + /** line = 위 탭(밑줄) · pill = 보조 탭(작은 알약) */ + kind?: "line" | "pill"; + size?: UiSize; + /** 칸에 붙일 data 이름(camelCase · 예: b08Tab → data-b08-tab) */ + data: string; + /** 칸 풍선 */ + title?: (key: string) => string | undefined; +} + +export function fillTabs(host: HTMLElement, opts: PageTabsOptions): HTMLElement[] { + const tabs = createTabs({ + tabs: opts.tabs, + value: opts.value, + kind: opts.kind, + size: opts.size, + onChange: (key) => key !== opts.value && opts.onChange(key), + }); + const buttons = [...tabs.root.querySelectorAll("[data-tab]")]; + for (const button of buttons) { + const key = button.dataset.tab ?? ""; + button.dataset[opts.data] = key; + const title = opts.title?.(key); + if (title) button.title = title; + } + host.classList.add(...tabs.root.classList); + host.dataset.size = tabs.root.dataset.size ?? ""; + host.setAttribute("role", "tablist"); + host.replaceChildren(...tabs.root.childNodes); + return buttons; +} diff --git a/A00_Common/spreadsheet/spreadsheet_tabs.ts b/A00_Common/spreadsheet/spreadsheet_tabs.ts index f4cc8a551..efdb0fe82 100644 --- a/A00_Common/spreadsheet/spreadsheet_tabs.ts +++ b/A00_Common/spreadsheet/spreadsheet_tabs.ts @@ -12,7 +12,7 @@ * ========================================================================== */ import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; -import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { createIconButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { emptySheet } from "./spreadsheet_commands"; import { hideSheet, unhideSheet } from "./spreadsheet_sheet_extra"; import { st } from "./spreadsheet_text"; @@ -164,11 +164,13 @@ export function mountTabs(ctx: SpreadsheetContext): PartHandle { } function addButton(): HTMLElement { - const btn = el("button", { - className: "ss-tabs__add", - text: "+", - attrs: { type: "button", title: st("SheetAdd") }, + const btn = createIconButton({ + glyph: "+", + title: st("SheetAdd"), + variant: "outline", + size: 22, }); + btn.classList.add("ss-tabs__add"); btn.addEventListener("click", () => { const name = nextSheetName(ctx.book, "Sheet"); const sheet = emptySheet(nextSheetId(ctx.book), name); diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.css b/A00_Common/spreadsheet/spreadsheet_toolbar.css index fc0ffa84f..92606abe7 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.css +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.css @@ -27,24 +27,10 @@ border-right: 0; } -.ss-toolbar .ui-btn { - padding: 4px 8px; - font-size: 0.82rem; -} - +/* 단추 · 칸 = 부품 단추(24) · 입력 · 고르기(28) — 여기는 켬 · 폭만(553-6) */ .ss-toolbar .ui-btn.is-on { - background: var(--color-mist-violet); - box-shadow: inset 0 0 0 1px var(--color-royal-amethyst); -} - -.ss-toolbar__select { - height: 28px; - padding: 0 4px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font-size: 0.8rem; + background: var(--color-accent-soft); + box-shadow: inset 0 0 0 1px var(--color-accent); } .ss-toolbar__select option { @@ -53,14 +39,7 @@ } .ss-toolbar__input { - height: 28px; width: 72px; - padding: 0 4px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); - font-size: 0.8rem; } .ss-toolbar__input--font { @@ -85,15 +64,6 @@ cursor: pointer; } -.ss-toolbar__clear { - padding: 0 4px; - border: 0; - background: none; - color: var(--color-text-secondary); - font-size: 0.72rem; - cursor: pointer; -} - .ss-toolbar__popover { position: absolute; top: calc(100% + 4px); @@ -138,7 +108,7 @@ .ss-toolbar__unit-label { margin-bottom: var(--spacing-4); color: var(--color-text-muted); - font-size: 0.72rem; + font-size: var(--text-caption); } .ss-toolbar__unit-grid { @@ -147,9 +117,9 @@ gap: var(--spacing-4); } -.ss-toolbar__unit-btn { +.ss-toolbar__unit-grid > .ui-btn[data-size] { min-width: 0; - padding: 4px 2px; + padding-inline: 2px; } .ss-toolbar__hint { @@ -232,15 +202,6 @@ font: inherit; } -.ss-tabs__add { - padding: 2px 8px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-secondary); - cursor: pointer; -} - /* ── 우클릭 메뉴(그리드 · 탭 공용, `ui_template_context_menu.ts`) — 모양 · 자리는 공용 `.ui-ctx-menu` ── */ .ss__context-menu-item.is-danger { @@ -261,7 +222,7 @@ display: flex; align-items: center; justify-content: center; - background: rgb(0 0 0 / 35%); + background: var(--color-scrim); } .ss-comment-editor__panel { diff --git a/A00_Common/spreadsheet/spreadsheet_toolbar.ts b/A00_Common/spreadsheet/spreadsheet_toolbar.ts index d9fccdb72..7ebda5a32 100644 --- a/A00_Common/spreadsheet/spreadsheet_toolbar.ts +++ b/A00_Common/spreadsheet/spreadsheet_toolbar.ts @@ -10,7 +10,7 @@ * 스스로 구독하지 않음. `destroy()` 는 부품을 뗄 때. * ========================================================================== */ -import { createButton, el } from "@ui/ui_template_elements"; +import { createButton, createIconButton, el, setUiSize } from "@ui/ui_template_elements"; import { confirmMerge } from "./spreadsheet_actions"; import { colName, rangeToA1, toA1 } from "./spreadsheet_address"; import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle"; @@ -144,14 +144,25 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { }; // ── 되돌리기 · 다시 ──────────────────────────────────────────────────── - const undoBtn = createButton({ label: "↶", variant: "ghost", onClick: () => ctx.undo() }); + const undoBtn = createButton({ + label: "↶", + variant: "ghost", + size: 24, + onClick: () => ctx.undo(), + }); undoBtn.title = st("Undo"); - const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() }); + const redoBtn = createButton({ + label: "↷", + variant: "ghost", + size: 24, + onClick: () => ctx.redo(), + }); redoBtn.title = st("Redo"); // 손가락 — 우클릭 대신 활성 칸 메뉴를 여는 ⋯(기기대응 3-5 · 터치 모드에서만 보임) const moreBtn = createButton({ label: "⋯", variant: "ghost", + size: 24, onClick: () => { const { r, c } = ctx.selection.활성; const { box } = ctx.grid.editorSlot(r, c); @@ -175,7 +186,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { // ── 글꼴 · 크기 ──────────────────────────────────────────────────────── const fontInput = el("input", { - className: "ss-toolbar__input ss-toolbar__input--font", + className: "ui-input ss-toolbar__input ss-toolbar__input--font", attrs: { list: "ss-toolbar-fonts", "aria-label": st("FontName") }, }) as HTMLInputElement; const fontList = el("datalist", { @@ -187,7 +198,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { fontInput.addEventListener("change", commitFont); const sizeInput = el("input", { - className: "ss-toolbar__input ss-toolbar__input--size", + className: "ui-input ss-toolbar__input ss-toolbar__input--size", attrs: { type: "number", min: "1", max: "409", step: "0.5", "aria-label": st("FontSize") }, }) as HTMLInputElement; sizeInput.addEventListener("change", () => { @@ -196,13 +207,13 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { }); // ── 굵게 · 기울임 · 밑줄 · 취소선 ────────────────────────────────────── - const boldBtn = createButton({ label: "B", variant: "ghost" }); + const boldBtn = createButton({ label: "B", variant: "ghost", size: 24 }); boldBtn.title = st("Bold"); - const italicBtn = createButton({ label: "I", variant: "ghost" }); + const italicBtn = createButton({ label: "I", variant: "ghost", size: 24 }); italicBtn.title = st("Italic"); - const underlineBtn = createButton({ label: "U", variant: "ghost" }); + const underlineBtn = createButton({ label: "U", variant: "ghost", size: 24 }); underlineBtn.title = st("Underline"); - const strikeBtn = createButton({ label: "S", variant: "ghost" }); + const strikeBtn = createButton({ label: "S", variant: "ghost", size: 24 }); strikeBtn.title = st("Strike"); const toggles: [HTMLButtonElement, keyof CellStyle][] = [ [boldBtn, "굵게"], @@ -220,11 +231,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { attrs: { type: "color", "aria-label": st("TextColor"), value: "#000000" }, }) as HTMLInputElement; textColor.addEventListener("change", () => patch({ 글자색: textColor.value })); - const textColorClear = el("button", { - className: "ss-toolbar__clear", - text: "✕", - attrs: { type: "button", title: st("ColorClear") }, - }); + const textColorClear = createIconButton({ glyph: "✕", title: st("ColorClear"), size: 18 }); + textColorClear.classList.add("ss-toolbar__clear"); textColorClear.addEventListener("click", () => patch({ 글자색: null })); const fillColor = el("input", { @@ -232,11 +240,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { attrs: { type: "color", "aria-label": st("FillColor"), value: "#ffffff" }, }) as HTMLInputElement; fillColor.addEventListener("change", () => patch({ 채움: fillColor.value })); - const fillColorClear = el("button", { - className: "ss-toolbar__clear", - text: "✕", - attrs: { type: "button", title: st("ColorClear") }, - }); + const fillColorClear = createIconButton({ glyph: "✕", title: st("ColorClear"), size: 18 }); + fillColorClear.classList.add("ss-toolbar__clear"); fillColorClear.addEventListener("click", () => patch({ 채움: null })); // ── 가로 · 세로 정렬 · 줄 바꿈 ───────────────────────────────────────── @@ -255,7 +260,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { } as const; const hAlignSelect = el("select", { - className: "ss-toolbar__select", + className: "ui-select ss-toolbar__select", attrs: { "aria-label": st("HAlign") }, children: (Object.keys(HALIGN_LABEL) as (keyof typeof HALIGN_LABEL)[]).map((v) => el("option", { attrs: { value: v }, text: HALIGN_LABEL[v] }), @@ -266,7 +271,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { ); const vAlignSelect = el("select", { - className: "ss-toolbar__select", + className: "ui-select ss-toolbar__select", attrs: { "aria-label": st("VAlign") }, children: (Object.keys(VALIGN_LABEL) as (keyof typeof VALIGN_LABEL)[]).map((v) => el("option", { attrs: { value: v }, text: VALIGN_LABEL[v] }), @@ -276,11 +281,11 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { patch({ 세로: vAlignSelect.value as CellStyle["세로"] }), ); - const wrapBtn = createButton({ label: st("Wrap"), variant: "ghost" }); + const wrapBtn = createButton({ label: st("Wrap"), variant: "ghost", size: 24 }); wrapBtn.addEventListener("click", () => patch({ 줄바꿈: !wrapBtn.classList.contains("is-on") })); // ── 병합 ─────────────────────────────────────────────────────────────── - const mergeBtn = createButton({ label: st("Merge"), variant: "ghost" }); + const mergeBtn = createButton({ label: st("Merge"), variant: "ghost", size: 24 }); mergeBtn.addEventListener("click", async () => { const range = ctx.selection.범위[0]; if (!range) return; @@ -290,10 +295,10 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { }); // ── 테두리 ─────────────────────────────────────────────────────────── - const borderBtn = createButton({ label: st("Border"), variant: "ghost" }); + const borderBtn = createButton({ label: st("Border"), variant: "ghost", size: 24 }); const borderPop = el("div", { className: "ss-toolbar__popover", attrs: { hidden: "true" } }); const lineSelect = el("select", { - className: "ss-toolbar__select", + className: "ui-select ss-toolbar__select", children: BORDER_LINES.map((v) => el("option", { attrs: { value: v }, text: v })), }) as HTMLSelectElement; const lineColor = el("input", { @@ -314,6 +319,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { const b = createButton({ label, variant: "ghost", + size: 24, onClick: () => { applyBorderPreset(preset); borderPop.hidden = true; @@ -393,7 +399,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { // ── 숫자 형식 ───────────────────────────────────────────────────────── const numfmtInput = el("input", { - className: "ss-toolbar__input ss-toolbar__input--numfmt", + className: "ui-input ss-toolbar__input ss-toolbar__input--numfmt", attrs: { list: "ss-toolbar-numfmt", "aria-label": st("NumberFormat"), @@ -414,7 +420,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { numfmtInput.addEventListener("change", () => patch({ 형식: numfmtInput.value || null })); // ── 단위 기호 ───────────────────────────────────────────────────────── - const unitBtn = createButton({ label: `${st("Unit")} ▾`, variant: "ghost" }); + const unitBtn = createButton({ label: `${st("Unit")} ▾`, variant: "ghost", size: 24 }); const unitPop = el("div", { className: "ss-toolbar__popover ss-toolbar__unit-pop", attrs: { hidden: "true" }, @@ -472,7 +478,12 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { recentGrid.textContent = ""; recentSection.hidden = list.length === 0; for (const u of list) { - const b = createButton({ label: u, variant: "ghost", onClick: () => insertUnit(u) }); + const b = createButton({ + label: u, + variant: "ghost", + size: 24, + onClick: () => insertUnit(u), + }); b.className += " ss-toolbar__unit-btn"; recentGrid.append(b); } @@ -483,7 +494,12 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { for (const [labelKey, items] of UNIT_GROUPS) { const grid = el("div", { className: "ss-toolbar__unit-grid" }); for (const u of items) { - const b = createButton({ label: u, variant: "ghost", onClick: () => insertUnit(u) }); + const b = createButton({ + label: u, + variant: "ghost", + size: 24, + onClick: () => insertUnit(u), + }); b.className += " ss-toolbar__unit-btn"; grid.append(b); } @@ -536,6 +552,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle { group(unitBtn, unitPop), formulaHint, ); + // 칸 크기 단(553-6) — 단추 24 · 칸 28 + for (const f of root.querySelectorAll(".ui-input, .ui-select")) setUiSize(f, 28); function refresh(): void { undoBtn.disabled = !ctx.history.canUndo(); diff --git a/A01_Home/A01_Home_UI_Page.ts b/A01_Home/A01_Home_UI_Page.ts index 110f0c492..53cb011d7 100644 --- a/A01_Home/A01_Home_UI_Page.ts +++ b/A01_Home/A01_Home_UI_Page.ts @@ -5,12 +5,15 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A01_Home_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createButton/createCard/createTag) 우선 사용. + * - 공통 부품(createPublicFrame/createCard/createBadge/createButton) 우선 사용. * - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createCard, createTag } from "@ui/ui_template_elements"; +import { createBadge } from "@ui/ui_template_badge"; +import { createButton, createCard } from "@ui/ui_template_elements"; +import { createPublicFrame } from "@ui/ui_template_public_layout"; +import { createText } from "@ui/ui_template_text"; import { navigateTo } from "../A00_Common/router"; import { ROUTES } from "@config/config_frontend"; import "./A01_Home_UI_Style.css"; @@ -49,57 +52,12 @@ function onA01_Home_Explore_Click(): void { /* ----------------------------------------------------------------------------- * 섹션 빌더 * -------------------------------------------------------------------------- */ -function buildHero(): HTMLElement { - const hero = document.createElement("section"); - hero.className = "a01-hero"; - - const inner = document.createElement("div"); - inner.className = "a01-hero__inner"; - - const title = document.createElement("h1"); - title.className = "a01-hero__title"; - title.textContent = L("A01_Home_Title"); - - const subtitle = document.createElement("p"); - subtitle.className = "a01-hero__subtitle"; - subtitle.textContent = L("A01_Home_Hero_Subtitle"); - - const ctas = document.createElement("div"); - ctas.className = "a01-hero__ctas"; - ctas.append( - createButton({ - label: L("A01_Home_Hero_CtaPrimary"), - variant: "filled", - onClick: onA01_Home_GetStarted_Click, - }), - createButton({ - label: L("A01_Home_Hero_CtaSecondary"), - variant: "ghost", - onClick: onA01_Home_Explore_Click, - }), - ); - - inner.append(title, subtitle, ctas); - hero.append(inner); - return hero; -} - function buildNews(): HTMLElement { - const section = document.createElement("section"); - section.className = "a01-section"; - - const heading = document.createElement("h2"); - heading.className = "a01-section__title"; - heading.textContent = L("A01_Home_News_SectionTitle"); - const list = document.createElement("div"); list.className = "a01-news-list"; for (const item of MOCK_NEWS) { - const row = document.createElement("div"); - row.className = "a01-news-row"; - - const tag = createTag(L("A01_Home_News_Tag"), "accent"); + const tag = createBadge(L("A01_Home_News_Tag"), "accent"); const titleEl = document.createElement("span"); titleEl.className = "a01-news-row__title"; titleEl.textContent = item.title; @@ -107,22 +65,14 @@ function buildNews(): HTMLElement { dateEl.className = "a01-news-row__date"; dateEl.textContent = item.date; - row.append(tag, titleEl, dateEl); + const row = createCard({ body: [tag, titleEl, dateEl] }); + row.classList.add("a01-news-row"); list.append(row); } - - section.append(heading, list); - return section; + return list; } function buildFeatures(): HTMLElement { - const section = document.createElement("section"); - section.className = "a01-section"; - - const heading = document.createElement("h2"); - heading.className = "a01-section__title"; - heading.textContent = L("A01_Home_Features_SectionTitle"); - const grid = document.createElement("div"); grid.className = "a01-feature-grid"; @@ -133,24 +83,34 @@ function buildFeatures(): HTMLElement { ]; for (const [titleKey, descKey] of features) { - const desc = document.createElement("p"); - desc.className = "a01-feature__desc"; - desc.textContent = L(descKey); - const card = createCard({ title: L(titleKey), body: [desc] }); + const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] }); card.classList.add("a01-feature-card"); grid.append(card); } - - section.append(heading, grid); - return section; + return grid; } /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderA01Home(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "a01-home"; - page.append(buildHero(), buildNews(), buildFeatures()); - root.append(page); + const frame = createPublicFrame({ + title: L("A01_Home_Title"), + lead: L("A01_Home_Hero_Subtitle"), + actions: [ + createButton({ + label: L("A01_Home_Hero_CtaPrimary"), + variant: "filled", + onClick: onA01_Home_GetStarted_Click, + }), + createButton({ + label: L("A01_Home_Hero_CtaSecondary"), + variant: "ghost", + onClick: onA01_Home_Explore_Click, + }), + ], + }); + frame.addSection(L("A01_Home_News_SectionTitle")).append(buildNews()); + frame.addSection(L("A01_Home_Features_SectionTitle")).append(buildFeatures()); + root.append(frame.root); } diff --git a/A01_Home/A01_Home_UI_Style.css b/A01_Home/A01_Home_UI_Style.css index bd3a21451..bc3bf0878 100644 --- a/A01_Home/A01_Home_UI_Style.css +++ b/A01_Home/A01_Home_UI_Style.css @@ -1,66 +1,8 @@ /* ============================================================================= * A01_Home_UI_Style.css - * 홈 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 홈 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄. * ========================================================================== */ -.a01-home { - display: flex; - flex-direction: column; - gap: var(--section-gap); - padding-bottom: var(--spacing-64); -} - -/* --- Hero --- */ -.a01-hero { - /* 라벤더 그라디언트는 흰 표면 아래에만: 히어로 배경으로 사용 */ - background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas)); - padding: var(--spacing-96) var(--spacing-32); -} -.a01-hero__inner { - max-width: var(--page-max-width); - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: var(--spacing-24); -} -.a01-hero__title { - font-size: var(--text-heading-lg); - line-height: var(--leading-heading-lg); - color: var(--color-deep-iris); - max-width: 14ch; -} -.a01-hero__subtitle { - font-size: var(--text-body); - line-height: var(--leading-body); - color: var(--color-slate); - max-width: 52ch; - margin: 0; -} -.a01-hero__ctas { - display: flex; - gap: var(--spacing-16); - margin-top: var(--spacing-8); -} - -/* --- 공통 섹션 --- */ -.a01-section { - max-width: var(--page-max-width); - margin: 0 auto; - width: 100%; - padding: 0 var(--spacing-32); - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} -.a01-section__title { - font-size: var(--text-heading-sm); - line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); - text-align: center; -} - /* --- News --- */ .a01-news-list { display: flex; @@ -68,21 +10,20 @@ gap: var(--spacing-8); } .a01-news-row { - display: flex; + padding: var(--spacing-16) var(--spacing-24); +} +.a01-news-row > .ui-card__body { + flex-direction: row; align-items: center; gap: var(--spacing-16); - padding: var(--spacing-16) var(--spacing-24); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); } .a01-news-row__title { flex: 1; - color: var(--color-charcoal); + color: var(--color-text); font-size: var(--text-body-sm); } .a01-news-row__date { - color: var(--color-ash); + color: var(--color-text-muted); font-size: var(--text-caption); } @@ -95,18 +36,9 @@ .a01-feature-card { min-height: 140px; } -.a01-feature__desc { - margin: 0; - color: var(--color-slate); - font-size: var(--text-body-sm); - line-height: var(--leading-body-sm); -} @media (max-width: 900px) { .a01-feature-grid { grid-template-columns: 1fr; } - .a01-hero__title { - font-size: var(--text-heading); - } } diff --git a/A02_ProgDetail/A02_ProgDetail_UI_Page.ts b/A02_ProgDetail/A02_ProgDetail_UI_Page.ts index 595fd2494..d87533d79 100644 --- a/A02_ProgDetail/A02_ProgDetail_UI_Page.ts +++ b/A02_ProgDetail/A02_ProgDetail_UI_Page.ts @@ -5,12 +5,14 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A02_ProgDetail_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createButton/createCard) 우선 사용. + * - 공통 부품(createPublicFrame/createCard/createButton) 우선 사용. * - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createCard } from "@ui/ui_template_elements"; +import { createPublicFrame } from "@ui/ui_template_public_layout"; +import { createText } from "@ui/ui_template_text"; import { navigateTo } from "../A00_Common/router"; import { ROUTES } from "@config/config_frontend"; import "./A02_ProgDetail_UI_Style.css"; @@ -30,40 +32,7 @@ function onA02_ProgDetail_Start_Click(): void { /* ----------------------------------------------------------------------------- * 섹션 빌더 * -------------------------------------------------------------------------- */ -function buildHero(): HTMLElement { - const hero = document.createElement("section"); - hero.className = "a02-hero"; - - const inner = document.createElement("div"); - inner.className = "a02-hero__inner"; - - const title = document.createElement("h1"); - title.className = "a02-hero__title"; - title.textContent = L("A02_ProgDetail_Title"); - - const subtitle = document.createElement("p"); - subtitle.className = "a02-hero__subtitle"; - subtitle.textContent = L("A02_ProgDetail_Hero_Subtitle"); - - const cta = createButton({ - label: L("A02_ProgDetail_Hero_Cta"), - variant: "filled", - onClick: onA02_ProgDetail_Start_Click, - }); - - inner.append(title, subtitle, cta); - hero.append(inner); - return hero; -} - function buildWorkflow(): HTMLElement { - const section = document.createElement("section"); - section.className = "a02-section"; - - const heading = document.createElement("h2"); - heading.className = "a02-section__title"; - heading.textContent = L("A02_ProgDetail_Workflow_SectionTitle"); - const list = document.createElement("div"); list.className = "a02-workflow-list"; @@ -77,9 +46,6 @@ function buildWorkflow(): HTMLElement { ]; steps.forEach(([titleKey, descKey], index) => { - const row = document.createElement("div"); - row.className = "a02-workflow-row"; - const badge = document.createElement("span"); badge.className = "a02-workflow-row__badge"; badge.textContent = String(index + 1); @@ -91,27 +57,15 @@ function buildWorkflow(): HTMLElement { stepTitle.className = "a02-workflow-row__title"; stepTitle.textContent = L(titleKey); - const stepDesc = document.createElement("p"); - stepDesc.className = "a02-workflow-row__desc"; - stepDesc.textContent = L(descKey); - - text.append(stepTitle, stepDesc); - row.append(badge, text); + text.append(stepTitle, createText(L(descKey), "muted", 14)); + const row = createCard({ body: [badge, text] }); + row.classList.add("a02-workflow-row"); list.append(row); }); - - section.append(heading, list); - return section; + return list; } function buildTech(): HTMLElement { - const section = document.createElement("section"); - section.className = "a02-section"; - - const heading = document.createElement("h2"); - heading.className = "a02-section__title"; - heading.textContent = L("A02_ProgDetail_Tech_SectionTitle"); - const grid = document.createElement("div"); grid.className = "a02-tech-grid"; @@ -122,24 +76,31 @@ function buildTech(): HTMLElement { ]; for (const [titleKey, descKey] of techs) { - const desc = document.createElement("p"); - desc.className = "a02-tech__desc"; - desc.textContent = L(descKey); - const card = createCard({ title: L(titleKey), body: [desc] }); + const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] }); card.classList.add("a02-tech-card"); grid.append(card); } - - section.append(heading, grid); - return section; + return grid; } /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderA02ProgDetail(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "a02-prog-detail"; - page.append(buildHero(), buildWorkflow(), buildTech()); - root.append(page); + const frame = createPublicFrame({ + title: L("A02_ProgDetail_Title"), + lead: L("A02_ProgDetail_Hero_Subtitle"), + actions: [ + createButton({ + label: L("A02_ProgDetail_Hero_Cta"), + variant: "filled", + onClick: onA02_ProgDetail_Start_Click, + }), + ], + wide: true, + }); + frame.root.classList.add("a02-prog-detail"); + frame.addSection(L("A02_ProgDetail_Workflow_SectionTitle")).append(buildWorkflow()); + frame.addSection(L("A02_ProgDetail_Tech_SectionTitle")).append(buildTech()); + root.append(frame.root); } diff --git a/A02_ProgDetail/A02_ProgDetail_UI_Style.css b/A02_ProgDetail/A02_ProgDetail_UI_Style.css index 0e6d57f2c..7b8d85415 100644 --- a/A02_ProgDetail/A02_ProgDetail_UI_Style.css +++ b/A02_ProgDetail/A02_ProgDetail_UI_Style.css @@ -1,58 +1,11 @@ /* ============================================================================= * A02_ProgDetail_UI_Style.css - * 프로그램 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 프로그램 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄. * ========================================================================== */ -.a02-prog-detail { - display: flex; - flex-direction: column; - gap: var(--section-gap); - padding-bottom: var(--spacing-64); -} - -/* --- Hero --- */ -.a02-hero { - background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas)); - padding: var(--spacing-96) var(--spacing-32); -} -.a02-hero__inner { - max-width: var(--page-max-width); - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: var(--spacing-24); -} -.a02-hero__title { - font-size: var(--text-heading-lg); - line-height: var(--leading-heading-lg); - color: var(--color-deep-iris); - max-width: 18ch; -} -.a02-hero__subtitle { - font-size: var(--text-body); - line-height: var(--leading-body); - color: var(--color-slate); - max-width: 56ch; - margin: 0; -} - -/* --- 공통 섹션 --- */ -.a02-section { - max-width: var(--page-max-width); - margin: 0 auto; - width: 100%; - padding: 0 var(--spacing-32); - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} -.a02-section__title { - font-size: var(--text-heading-sm); - line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); - text-align: center; +/* 단추 하나뿐인 머리 — 단추 줄 위 여백 없음 */ +.a02-prog-detail .ui-public-frame__actions { + margin-top: 0; } /* --- Workflow (번호 배지 + 텍스트) --- */ @@ -61,14 +14,10 @@ flex-direction: column; gap: var(--spacing-16); } -.a02-workflow-row { - display: flex; +.a02-workflow-row > .ui-card__body { + flex-direction: row; align-items: flex-start; gap: var(--spacing-24); - padding: var(--spacing-24); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); } .a02-workflow-row__badge { flex-shrink: 0; @@ -90,15 +39,9 @@ } .a02-workflow-row__title { font-size: var(--text-body); - color: var(--color-charcoal); + color: var(--color-text); margin: 0; } -.a02-workflow-row__desc { - margin: 0; - color: var(--color-slate); - font-size: var(--text-body-sm); - line-height: var(--leading-body-sm); -} /* --- Tech --- */ .a02-tech-grid { @@ -109,18 +52,9 @@ .a02-tech-card { min-height: 140px; } -.a02-tech__desc { - margin: 0; - color: var(--color-slate); - font-size: var(--text-body-sm); - line-height: var(--leading-body-sm); -} @media (max-width: 900px) { .a02-tech-grid { grid-template-columns: 1fr; } - .a02-hero__title { - font-size: var(--text-heading); - } } diff --git a/A03_CompDetail/A03_CompDetail_UI_Page.ts b/A03_CompDetail/A03_CompDetail_UI_Page.ts index 0e1bb5bf8..024508d5e 100644 --- a/A03_CompDetail/A03_CompDetail_UI_Page.ts +++ b/A03_CompDetail/A03_CompDetail_UI_Page.ts @@ -5,11 +5,13 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A03_CompDetail_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createCard) 우선 사용. + * - 공통 부품(createPublicFrame/createCard) 우선 사용. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createCard } from "@ui/ui_template_elements"; +import { createPublicFrame } from "@ui/ui_template_public_layout"; +import { createText } from "@ui/ui_template_text"; import "./A03_CompDetail_UI_Style.css"; /** locale 헬퍼 */ @@ -20,50 +22,14 @@ function L(key: keyof typeof ui_locales): string { /* ----------------------------------------------------------------------------- * 섹션 빌더 * -------------------------------------------------------------------------- */ -function buildHero(): HTMLElement { - const hero = document.createElement("section"); - hero.className = "a03-hero"; - - const inner = document.createElement("div"); - inner.className = "a03-hero__inner"; - - const title = document.createElement("h1"); - title.className = "a03-hero__title"; - title.textContent = L("A03_CompDetail_Title"); - - const subtitle = document.createElement("p"); - subtitle.className = "a03-hero__subtitle"; - subtitle.textContent = L("A03_CompDetail_Hero_Subtitle"); - - inner.append(title, subtitle); - hero.append(inner); - return hero; -} - function buildMission(): HTMLElement { - const section = document.createElement("section"); - section.className = "a03-section"; - - const heading = document.createElement("h2"); - heading.className = "a03-section__title"; - heading.textContent = L("A03_CompDetail_Mission_SectionTitle"); - const body = document.createElement("p"); body.className = "a03-mission__body"; body.textContent = L("A03_CompDetail_Mission_Body"); - - section.append(heading, body); - return section; + return body; } function buildValues(): HTMLElement { - const section = document.createElement("section"); - section.className = "a03-section"; - - const heading = document.createElement("h2"); - heading.className = "a03-section__title"; - heading.textContent = L("A03_CompDetail_Value_SectionTitle"); - const grid = document.createElement("div"); grid.className = "a03-value-grid"; @@ -74,40 +40,31 @@ function buildValues(): HTMLElement { ]; for (const [titleKey, descKey] of values) { - const desc = document.createElement("p"); - desc.className = "a03-value__desc"; - desc.textContent = L(descKey); - const card = createCard({ title: L(titleKey), body: [desc] }); + const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] }); card.classList.add("a03-value-card"); grid.append(card); } - - section.append(heading, grid); - return section; + return grid; } function buildContact(): HTMLElement { - const section = document.createElement("section"); - section.className = "a03-section"; - - const heading = document.createElement("h2"); - heading.className = "a03-section__title"; - heading.textContent = L("A03_CompDetail_Contact_SectionTitle"); - const email = document.createElement("p"); email.className = "a03-contact__email"; email.textContent = L("A03_CompDetail_Contact_Email"); - - section.append(heading, email); - return section; + return email; } /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderA03CompDetail(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "a03-comp-detail"; - page.append(buildHero(), buildMission(), buildValues(), buildContact()); - root.append(page); + const frame = createPublicFrame({ + title: L("A03_CompDetail_Title"), + lead: L("A03_CompDetail_Hero_Subtitle"), + wide: true, + }); + frame.addSection(L("A03_CompDetail_Mission_SectionTitle")).append(buildMission()); + frame.addSection(L("A03_CompDetail_Value_SectionTitle")).append(buildValues()); + frame.addSection(L("A03_CompDetail_Contact_SectionTitle")).append(buildContact()); + root.append(frame.root); } diff --git a/A03_CompDetail/A03_CompDetail_UI_Style.css b/A03_CompDetail/A03_CompDetail_UI_Style.css index 5fb77e907..97ac0c55f 100644 --- a/A03_CompDetail/A03_CompDetail_UI_Style.css +++ b/A03_CompDetail/A03_CompDetail_UI_Style.css @@ -1,66 +1,14 @@ /* ============================================================================= * A03_CompDetail_UI_Style.css - * 회사 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 회사 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄. * ========================================================================== */ -.a03-comp-detail { - display: flex; - flex-direction: column; - gap: var(--section-gap); - padding-bottom: var(--spacing-64); -} - -/* --- Hero --- */ -.a03-hero { - background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas)); - padding: var(--spacing-96) var(--spacing-32); -} -.a03-hero__inner { - max-width: var(--page-max-width); - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: var(--spacing-24); -} -.a03-hero__title { - font-size: var(--text-heading-lg); - line-height: var(--leading-heading-lg); - color: var(--color-deep-iris); - max-width: 18ch; -} -.a03-hero__subtitle { - font-size: var(--text-body); - line-height: var(--leading-body); - color: var(--color-slate); - max-width: 56ch; - margin: 0; -} - -/* --- 공통 섹션 --- */ -.a03-section { - max-width: var(--page-max-width); - margin: 0 auto; - width: 100%; - padding: 0 var(--spacing-32); - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} -.a03-section__title { - font-size: var(--text-heading-sm); - line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); - text-align: center; -} - /* --- Mission --- */ .a03-mission__body { margin: 0 auto; max-width: 60ch; text-align: center; - color: var(--color-slate); + color: var(--color-text-secondary); font-size: var(--text-body); line-height: var(--leading-body); } @@ -74,18 +22,12 @@ .a03-value-card { min-height: 140px; } -.a03-value__desc { - margin: 0; - color: var(--color-slate); - font-size: var(--text-body-sm); - line-height: var(--leading-body-sm); -} /* --- Contact --- */ .a03-contact__email { margin: 0; text-align: center; - color: var(--color-charcoal); + color: var(--color-text); font-size: var(--text-body); } @@ -93,7 +35,4 @@ .a03-value-grid { grid-template-columns: 1fr; } - .a03-hero__title { - font-size: var(--text-heading); - } } diff --git a/A04_NewsHistory/A04_NewsHistory_UI_Page.ts b/A04_NewsHistory/A04_NewsHistory_UI_Page.ts index ab4dbadab..d237b8275 100644 --- a/A04_NewsHistory/A04_NewsHistory_UI_Page.ts +++ b/A04_NewsHistory/A04_NewsHistory_UI_Page.ts @@ -5,12 +5,15 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A04_NewsHistory_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createTag) 우선 사용. + * - 공통 부품(createPublicFrame/createCard/createBadge) 우선 사용. * - Mock 데이터는 나중에 GET /api/a04/history로 교체. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createTag, type TagVariant } from "@ui/ui_template_elements"; +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { createCard } from "@ui/ui_template_elements"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createPublicFrame } from "@ui/ui_template_public_layout"; import "./A04_NewsHistory_UI_Style.css"; /** locale 헬퍼 */ @@ -38,7 +41,7 @@ const MOCK_HISTORY: HistoryItem[] = [ ]; /** 이력 종류별 태그 변형/라벨 매핑 */ -const KIND_META: Record = { +const KIND_META: Record = { feature: { variant: "accent", labelKey: "A04_NewsHistory_Tag_Feature" }, fix: { variant: "success", labelKey: "A04_NewsHistory_Tag_Fix" }, notice: { variant: "neutral", labelKey: "A04_NewsHistory_Tag_Notice" }, @@ -47,47 +50,14 @@ const KIND_META: Record .ui-card__body { + flex-direction: row; align-items: center; gap: var(--spacing-16); - padding: var(--spacing-16) var(--spacing-24); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); } .a04-timeline-row__title { flex: 1; - color: var(--color-charcoal); + color: var(--color-text); font-size: var(--text-body-sm); } .a04-timeline-row__date { - color: var(--color-ash); + color: var(--color-text-muted); font-size: var(--text-caption); } -.a04-timeline__empty { - text-align: center; - color: var(--color-slate); - font-size: var(--text-body); -} - -@media (max-width: 900px) { - .a04-hero__title { - font-size: var(--text-heading); - } -} diff --git a/A05_EduDetail/A05_EduDetail_UI_Page.ts b/A05_EduDetail/A05_EduDetail_UI_Page.ts index 2012da88e..b12a561ae 100644 --- a/A05_EduDetail/A05_EduDetail_UI_Page.ts +++ b/A05_EduDetail/A05_EduDetail_UI_Page.ts @@ -5,12 +5,14 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A05_EduDetail_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createButton/createCard) 우선 사용. + * - 공통 부품(createPublicFrame/createCard/createButton) 우선 사용. * - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명. * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createCard } from "@ui/ui_template_elements"; +import { createPublicFrame } from "@ui/ui_template_public_layout"; +import { createText } from "@ui/ui_template_text"; import { navigateTo } from "../A00_Common/router"; import { ROUTES } from "@config/config_frontend"; import "./A05_EduDetail_UI_Style.css"; @@ -30,34 +32,7 @@ function onA05_EduDetail_Inquiry_Click(): void { /* ----------------------------------------------------------------------------- * 섹션 빌더 * -------------------------------------------------------------------------- */ -function buildHero(): HTMLElement { - const hero = document.createElement("section"); - hero.className = "a05-hero"; - - const inner = document.createElement("div"); - inner.className = "a05-hero__inner"; - - const title = document.createElement("h1"); - title.className = "a05-hero__title"; - title.textContent = L("A05_EduDetail_Title"); - - const subtitle = document.createElement("p"); - subtitle.className = "a05-hero__subtitle"; - subtitle.textContent = L("A05_EduDetail_Hero_Subtitle"); - - inner.append(title, subtitle); - hero.append(inner); - return hero; -} - function buildCourses(): HTMLElement { - const section = document.createElement("section"); - section.className = "a05-section"; - - const heading = document.createElement("h2"); - heading.className = "a05-section__title"; - heading.textContent = L("A05_EduDetail_Course_SectionTitle"); - const grid = document.createElement("div"); grid.className = "a05-course-grid"; @@ -68,38 +43,31 @@ function buildCourses(): HTMLElement { ]; for (const [titleKey, descKey] of courses) { - const desc = document.createElement("p"); - desc.className = "a05-course__desc"; - desc.textContent = L(descKey); - const card = createCard({ title: L(titleKey), body: [desc] }); + const card = createCard({ title: L(titleKey), body: [createText(L(descKey), "muted", 14)] }); card.classList.add("a05-course-card"); grid.append(card); } - - section.append(heading, grid); - return section; -} - -function buildCta(): HTMLElement { - const section = document.createElement("section"); - section.className = "a05-section a05-cta"; - - section.append( - createButton({ - label: L("A05_EduDetail_Cta"), - variant: "filled", - onClick: onA05_EduDetail_Inquiry_Click, - }), - ); - return section; + return grid; } /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ export function renderA05EduDetail(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "a05-edu-detail"; - page.append(buildHero(), buildCourses(), buildCta()); - root.append(page); + const frame = createPublicFrame({ + title: L("A05_EduDetail_Title"), + lead: L("A05_EduDetail_Hero_Subtitle"), + wide: true, + }); + frame.addSection(L("A05_EduDetail_Course_SectionTitle")).append(buildCourses()); + const cta = frame.addSection(); + cta.classList.add("a05-cta"); + cta.append( + createButton({ + label: L("A05_EduDetail_Cta"), + variant: "filled", + onClick: onA05_EduDetail_Inquiry_Click, + }), + ); + root.append(frame.root); } diff --git a/A05_EduDetail/A05_EduDetail_UI_Style.css b/A05_EduDetail/A05_EduDetail_UI_Style.css index cc025e28b..c36e0df2b 100644 --- a/A05_EduDetail/A05_EduDetail_UI_Style.css +++ b/A05_EduDetail/A05_EduDetail_UI_Style.css @@ -1,60 +1,8 @@ /* ============================================================================= * A05_EduDetail_UI_Style.css - * 교육 상세 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 교육 상세 페이지 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄. * ========================================================================== */ -.a05-edu-detail { - display: flex; - flex-direction: column; - gap: var(--section-gap); - padding-bottom: var(--spacing-64); -} - -/* --- Hero --- */ -.a05-hero { - background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas)); - padding: var(--spacing-96) var(--spacing-32); -} -.a05-hero__inner { - max-width: var(--page-max-width); - margin: 0 auto; - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - gap: var(--spacing-24); -} -.a05-hero__title { - font-size: var(--text-heading-lg); - line-height: var(--leading-heading-lg); - color: var(--color-deep-iris); - max-width: 18ch; -} -.a05-hero__subtitle { - font-size: var(--text-body); - line-height: var(--leading-body); - color: var(--color-slate); - max-width: 56ch; - margin: 0; -} - -/* --- 공통 섹션 --- */ -.a05-section { - max-width: var(--page-max-width); - margin: 0 auto; - width: 100%; - padding: 0 var(--spacing-32); - display: flex; - flex-direction: column; - gap: var(--spacing-24); -} -.a05-section__title { - font-size: var(--text-heading-sm); - line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); - text-align: center; -} - /* --- Courses --- */ .a05-course-grid { display: grid; @@ -64,12 +12,6 @@ .a05-course-card { min-height: 140px; } -.a05-course__desc { - margin: 0; - color: var(--color-slate); - font-size: var(--text-body-sm); - line-height: var(--leading-body-sm); -} /* --- CTA --- */ .a05-cta { @@ -80,7 +22,4 @@ .a05-course-grid { grid-template-columns: 1fr; } - .a05-hero__title { - font-size: var(--text-heading); - } } diff --git a/A06_Login/A06_Login_UI_Auth_Page.ts b/A06_Login/A06_Login_UI_Auth_Page.ts index 45e92a9af..36497618f 100644 --- a/A06_Login/A06_Login_UI_Auth_Page.ts +++ b/A06_Login/A06_Login_UI_Auth_Page.ts @@ -2,6 +2,7 @@ import { ROUTES } from "@config/config_frontend"; import { isBlank, isValidEmail } from "@util/common_util_validate"; import { createButton, + createCard, createInputField, hideLoadingOverlay, showLoadingOverlay, @@ -67,8 +68,6 @@ function clearOtpPending(): void { export function renderA06Login(root: HTMLElement): void { const page = document.createElement("div"); page.className = "a06-login"; - const card = document.createElement("div"); - card.className = "a06-login__card"; const title = document.createElement("h1"); title.className = "a06-login__title"; title.textContent = L("A06_Login_Title"); @@ -215,7 +214,8 @@ export function renderA06Login(root: HTMLElement): void { variant: "ghost", onClick: () => navigateTo(ROUTES.A07_REGISTER), }); - card.append(title, form, register); + const card = createCard({ body: [title, form, register] }); + card.classList.add("a06-login__card"); page.append(card); root.append(page); diff --git a/A06_Login/A06_Login_UI_Style.css b/A06_Login/A06_Login_UI_Style.css index b88717ea5..9a56c358f 100644 --- a/A06_Login/A06_Login_UI_Style.css +++ b/A06_Login/A06_Login_UI_Style.css @@ -1,6 +1,6 @@ /* ============================================================================= * A06_Login_UI_Style.css - * 로그인 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 로그인 페이지 고유 배치 — 카드 겉은 공용 부품(카드)이 냄. * ========================================================================== */ .a06-login { @@ -13,29 +13,16 @@ .a06-login__card { width: 100%; max-width: 400px; - display: flex; - flex-direction: column; - gap: var(--spacing-16); padding: var(--spacing-40); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - box-shadow: var(--shadow-sm); } .a06-login__title { font-size: var(--text-heading-sm); line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); + color: var(--color-text); text-align: center; margin: 0; } -.a06-login__subtitle { - font-size: var(--text-body-sm); - color: var(--color-slate); - text-align: center; - margin: 0 0 var(--spacing-8); -} .a06-login__form { display: flex; @@ -50,10 +37,3 @@ .a06-login__otp-actions > .ui-btn { flex: 1; } -.a06-login__submit { - width: 100%; - margin-top: var(--spacing-8); -} -.a06-login__link { - width: 100%; -} diff --git a/A07_Register/A07_Register_UI_Auth_Page.ts b/A07_Register/A07_Register_UI_Auth_Page.ts index 4dd8a8097..a86591cfd 100644 --- a/A07_Register/A07_Register_UI_Auth_Page.ts +++ b/A07_Register/A07_Register_UI_Auth_Page.ts @@ -2,11 +2,14 @@ import { ROUTES } from "@config/config_frontend"; import { hasMinLength, isBlank, isValidEmail } from "@util/common_util_validate"; import { createButton, + createCard, createInputField, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { attachCollapsible } from "@ui/ui_template_collapsible"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { navigateTo } from "../A00_Common/router"; import { requestRegistration, verifyRegistration } from "./A07_Register_Api_Fetch"; @@ -22,38 +25,29 @@ interface AgreementField { function checkboxWithAccordion(label: string, contentText: string): AgreementField { const container = document.createElement("div"); - container.className = "a07-register__agreement"; + container.className = "ui-collapsible is-collapsed a07-register__agreement"; const summary = document.createElement("div"); - summary.className = "a07-register__agreement-summary"; + summary.className = "ui-collapsible__title a07-register__agreement-summary"; - const input = document.createElement("input"); - input.type = "checkbox"; + const box = createCheckbox(""); + // 체크 상자를 누를 땐 접힘이 바뀌지 않게 — 줄의 나머지를 누르면 약관이 펴짐 + box.root.addEventListener("click", (e) => e.stopPropagation()); const labelSpan = document.createElement("span"); labelSpan.className = "a07-register__agreement-label"; labelSpan.textContent = label; - summary.append(input, labelSpan); + summary.append(box.root, labelSpan); - const details = document.createElement("details"); - details.className = "a07-register__agreement-details"; + const content = document.createElement("div"); + content.className = "a07-register__agreement-content"; + content.textContent = contentText; - const detailsContent = document.createElement("div"); - detailsContent.className = "a07-register__agreement-content"; - detailsContent.textContent = contentText; + container.append(summary, content); + attachCollapsible(container); - details.appendChild(detailsContent); - - summary.addEventListener("click", (e) => { - if (e.target !== input) { - details.open = !details.open; - } - }); - - container.append(summary, details); - - return { root: container, input }; + return { root: container, input: box.input }; } async function loadAgreementText(filename: string): Promise { @@ -69,8 +63,6 @@ async function loadAgreementText(filename: string): Promise { export function renderA07Register(root: HTMLElement): void { const page = document.createElement("div"); page.className = "a07-register"; - const card = document.createElement("div"); - card.className = "a07-register__card"; const title = document.createElement("h1"); title.className = "a07-register__title"; title.textContent = L("A07_Register_Title"); @@ -210,7 +202,8 @@ export function renderA07Register(root: HTMLElement): void { variant: "ghost", onClick: () => navigateTo(ROUTES.A06_LOGIN), }); - card.append(title, form, login); + const card = createCard({ body: [title, form, login] }); + card.classList.add("a07-register__card"); page.append(card); root.append(page); }); diff --git a/A07_Register/A07_Register_UI_Page.ts b/A07_Register/A07_Register_UI_Page.ts deleted file mode 100644 index c9adb23be..000000000 --- a/A07_Register/A07_Register_UI_Page.ts +++ /dev/null @@ -1,141 +0,0 @@ -/* ============================================================================= - * A07_Register_UI_Page.ts - * 로그인 전 07: 회원가입 (회사명 + 이름 + 이메일 + 비밀번호 폼) - * - * 제약 준수: - * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). - * - 색상/간격은 A07_Register_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createButton/createInputField) 우선 사용. - * - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명. - * - 백엔드 전송 전 1차 유효성 검사 (frontend.md §4). - * ========================================================================== */ - -import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField } from "@ui/ui_template_elements"; -import { isBlank, isValidEmail, hasMinLength } from "@util/common_util_validate"; -import { navigateTo } from "../A00_Common/router"; -import { ROUTES } from "@config/config_frontend"; -import "./A07_Register_UI_Style.css"; - -/** locale 헬퍼 */ -function L(key: keyof typeof ui_locales): string { - return ui_locales[key][currentLanguageIndex]; -} - -/** 비밀번호 최소 길이 */ -const PASSWORD_MIN_LENGTH = 8; - -/* ----------------------------------------------------------------------------- - * 페이지 진입점 - * -------------------------------------------------------------------------- */ -export function renderA07Register(root: HTMLElement): void { - const page = document.createElement("div"); - page.className = "a07-register"; - - const card = document.createElement("div"); - card.className = "a07-register__card"; - - const title = document.createElement("h1"); - title.className = "a07-register__title"; - title.textContent = L("A07_Register_Title"); - - const subtitle = document.createElement("p"); - subtitle.className = "a07-register__subtitle"; - subtitle.textContent = L("A07_Register_Subtitle"); - - // 입력 필드 - const companyField = createInputField({ - label: L("A07_Register_Field_Company"), - placeholder: L("A07_Register_Field_Company_Placeholder"), - type: "text", - required: true, - }); - const nameField = createInputField({ - label: L("A07_Register_Field_Name"), - placeholder: L("A07_Register_Field_Name_Placeholder"), - type: "text", - required: true, - }); - const emailField = createInputField({ - label: L("A07_Register_Field_Email"), - placeholder: L("A07_Register_Field_Email_Placeholder"), - type: "email", - required: true, - }); - const passwordField = createInputField({ - label: L("A07_Register_Field_Password"), - placeholder: L("A07_Register_Field_Password_Placeholder"), - type: "password", - required: true, - }); - - const form = document.createElement("form"); - form.className = "a07-register__form"; - form.noValidate = true; - - const submitBtn = createButton({ - label: L("A07_Register_Submit"), - variant: "filled", - type: "submit", - }); - submitBtn.classList.add("a07-register__submit"); - - /* 이벤트 핸들러: 제출 시 1차 유효성 검사 */ - function onA07_Register_Submit_Click(event: Event): void { - event.preventDefault(); - [companyField, nameField, emailField, passwordField].forEach((f) => f.setError()); - - const company = companyField.input.value; - const name = nameField.input.value; - const email = emailField.input.value; - const password = passwordField.input.value; - - // 필수값 검사 - let hasError = false; - if (isBlank(company)) { - companyField.setError(L("A07_Register_Error_Required")); - hasError = true; - } - if (isBlank(name)) { - nameField.setError(L("A07_Register_Error_Required")); - hasError = true; - } - if (isBlank(email)) { - emailField.setError(L("A07_Register_Error_Required")); - hasError = true; - } - if (isBlank(password)) { - passwordField.setError(L("A07_Register_Error_Required")); - hasError = true; - } - if (hasError) return; - - // 형식 검사 - if (!isValidEmail(email)) { - emailField.setError(L("A07_Register_Error_Email")); - return; - } - if (!hasMinLength(password, PASSWORD_MIN_LENGTH)) { - passwordField.setError(L("A07_Register_Error_Password")); - return; - } - - // TODO: POST /api/a07/register 연동. 성공 시 로그인 페이지로. - navigateTo(ROUTES.A06_LOGIN); - } - - form.addEventListener("submit", onA07_Register_Submit_Click); - form.append(companyField.root, nameField.root, emailField.root, passwordField.root, submitBtn); - - // 로그인 링크 - const toLogin = createButton({ - label: L("A07_Register_ToLogin"), - variant: "ghost", - onClick: () => navigateTo(ROUTES.A06_LOGIN), - }); - toLogin.classList.add("a07-register__link"); - - card.append(title, subtitle, form, toLogin); - page.append(card); - root.append(page); -} diff --git a/A07_Register/A07_Register_UI_Style.css b/A07_Register/A07_Register_UI_Style.css index baa1c21bb..745d864f7 100644 --- a/A07_Register/A07_Register_UI_Style.css +++ b/A07_Register/A07_Register_UI_Style.css @@ -1,6 +1,6 @@ /* ============================================================================= * A07_Register_UI_Style.css - * 회원가입 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 회원가입 페이지 고유 배치 — 카드 · 체크 · 접이 겉은 공용 부품이 냄. * ========================================================================== */ .a07-register { @@ -13,49 +13,22 @@ .a07-register__card { width: 100%; max-width: 420px; - display: flex; - flex-direction: column; - gap: var(--spacing-16); padding: var(--spacing-40); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - box-shadow: var(--shadow-sm); } .a07-register__title { font-size: var(--text-heading-sm); line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); + color: var(--color-text); text-align: center; margin: 0; } -.a07-register__subtitle { - font-size: var(--text-body-sm); - color: var(--color-slate); - text-align: center; - margin: 0 0 var(--spacing-8); -} .a07-register__form { display: flex; flex-direction: column; gap: var(--spacing-16); } -.a07-register__submit { - width: 100%; - margin-top: var(--spacing-8); -} -.a07-register__link { - width: 100%; -} -.a07-register__check { - display: flex; - align-items: center; - gap: var(--spacing-8); - color: var(--color-text); - font-size: var(--text-body-sm); -} .a07-register__agreement { border: 1px solid var(--color-border); @@ -64,17 +37,9 @@ } .a07-register__agreement-summary { - display: flex; - align-items: center; gap: var(--spacing-8); padding: var(--spacing-12) var(--spacing-16); background-color: var(--color-canvas); - cursor: pointer; - user-select: none; -} - -.a07-register__agreement-summary input[type="checkbox"] { - flex-shrink: 0; } .a07-register__agreement-label { @@ -83,20 +48,14 @@ color: var(--color-text); } -.a07-register__agreement-details { - border-top: 1px solid var(--color-border); -} - -.a07-register__agreement-details[open] { - padding: var(--spacing-12) var(--spacing-16); -} - .a07-register__agreement-content { max-height: 200px; overflow-y: auto; + padding: var(--spacing-12) var(--spacing-16); + border-top: 1px solid var(--color-border); white-space: pre-wrap; word-break: break-word; font-size: var(--text-body-sm); - color: var(--color-slate); + color: var(--color-text-secondary); line-height: var(--leading-relaxed); } diff --git a/A08_Support/A08_Support_UI_Page.ts b/A08_Support/A08_Support_UI_Page.ts index 3f050a1eb..43d4ca2df 100644 --- a/A08_Support/A08_Support_UI_Page.ts +++ b/A08_Support/A08_Support_UI_Page.ts @@ -5,13 +5,14 @@ * 제약 준수: * - 모든 문구는 ui_template_locale.ts 참조 (하드코딩 금지). * - 색상/간격은 A08_Support_UI_Style.css + theme.css 변수만 사용. - * - 공통 컴포넌트(createButton/createInputField) 우선 사용. + * - 공통 부품(createCard/createBanner/createInputField/createButton) 우선 사용. * - 이벤트 핸들러는 on[페이지]_[기능]_[액션] 명명. * - 백엔드 전송 전 1차 유효성 검사 (frontend.md §4). * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton, createInputField } from "@ui/ui_template_elements"; +import { createBanner } from "@ui/ui_template_banner"; +import { createButton, createCard, createInputField } from "@ui/ui_template_elements"; import { isBlank, isValidEmail } from "@util/common_util_validate"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import "./A08_Support_UI_Style.css"; @@ -21,48 +22,6 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } -/** 문의내용 textarea 핸들 (createInputField와 유사한 형태로 에러 슬롯 포함) */ -interface TextAreaHandle { - root: HTMLDivElement; - textarea: HTMLTextAreaElement; - setError: (msg?: string) => void; -} - -/** 여러 줄 입력용 textarea 필드 생성 (공통 .ui-field/.ui-input 스타일 상속). */ -function createTextAreaField(label: string, placeholder: string): TextAreaHandle { - const root = document.createElement("div"); - root.className = "ui-field"; - - const labelEl = document.createElement("label"); - labelEl.className = "ui-field__label"; - labelEl.textContent = label; - - const textarea = document.createElement("textarea"); - textarea.className = "ui-input a08-support__textarea"; - textarea.placeholder = placeholder; - textarea.rows = 6; - - const errorSlot = document.createElement("span"); - errorSlot.className = "ui-field__error"; - errorSlot.setAttribute("role", "alert"); - - root.append(labelEl, textarea, errorSlot); - - const setError = (msg?: string): void => { - if (msg) { - textarea.classList.add("ui-input--error"); - errorSlot.textContent = msg; - errorSlot.classList.add("is-visible"); - } else { - textarea.classList.remove("ui-input--error"); - errorSlot.textContent = ""; - errorSlot.classList.remove("is-visible"); - } - }; - - return { root, textarea, setError }; -} - /* ----------------------------------------------------------------------------- * 페이지 진입점 * -------------------------------------------------------------------------- */ @@ -70,9 +29,6 @@ export function renderA08Support(root: HTMLElement): void { const page = document.createElement("div"); page.className = "a08-support"; - const card = document.createElement("div"); - card.className = "a08-support__card"; - const title = document.createElement("h1"); title.className = "a08-support__title"; title.textContent = L("A08_Support_Title"); @@ -82,9 +38,7 @@ export function renderA08Support(root: HTMLElement): void { subtitle.textContent = L("A08_Support_Subtitle"); // 성공 메시지 슬롯 (제출 성공 시 표시) - const successMsg = document.createElement("p"); - successMsg.className = "a08-support__success"; - successMsg.textContent = L("A08_Support_Success"); + const successMsg = createBanner({ tone: "success", text: L("A08_Support_Success") }); successMsg.hidden = true; // 입력 필드 @@ -113,10 +67,11 @@ export function renderA08Support(root: HTMLElement): void { type: "text", required: true, }); - const messageField = createTextAreaField( - L("A08_Support_Field_Message"), - L("A08_Support_Field_Message_Placeholder"), - ); + const messageField = createInputField({ + label: L("A08_Support_Field_Message"), + placeholder: L("A08_Support_Field_Message_Placeholder"), + multiline: 6, + }); const form = document.createElement("form"); form.className = "a08-support__form"; @@ -142,7 +97,7 @@ export function renderA08Support(root: HTMLElement): void { const email = emailField.input.value; const phone = phoneField.input.value; const subject = subjectField.input.value; - const message = messageField.textarea.value; + const message = messageField.input.value; // 필수값 검사 let hasError = false; @@ -184,7 +139,7 @@ export function renderA08Support(root: HTMLElement): void { throw new Error(error.detail ?? "요청 실패"); } form.reset(); - messageField.textarea.value = ""; + messageField.input.value = ""; successMsg.hidden = false; } catch (err) { const msg = err instanceof Error ? err.message : "오류 발생"; @@ -203,7 +158,8 @@ export function renderA08Support(root: HTMLElement): void { submitBtn, ); - card.append(title, subtitle, successMsg, form); + const card = createCard({ body: [title, subtitle, successMsg, form] }); + card.classList.add("a08-support__card"); page.append(card); root.append(page); diff --git a/A08_Support/A08_Support_UI_Style.css b/A08_Support/A08_Support_UI_Style.css index 07eaf5c2e..eb636bfad 100644 --- a/A08_Support/A08_Support_UI_Style.css +++ b/A08_Support/A08_Support_UI_Style.css @@ -1,6 +1,6 @@ /* ============================================================================= * A08_Support_UI_Style.css - * 기술지원 요청 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지). + * 기술지원 요청 페이지 고유 배치 — 카드 · 알림 · 칸 겉은 공용 부품이 냄. * ========================================================================== */ .a08-support { @@ -13,50 +13,28 @@ .a08-support__card { width: 100%; max-width: 520px; - display: flex; - flex-direction: column; - gap: var(--spacing-16); padding: var(--spacing-40); - background-color: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - box-shadow: var(--shadow-sm); } .a08-support__title { font-size: var(--text-heading-sm); line-height: var(--leading-heading-sm); - color: var(--color-deep-iris); + color: var(--color-text); text-align: center; margin: 0; } .a08-support__subtitle { font-size: var(--text-body-sm); - color: var(--color-slate); + color: var(--color-text-secondary); text-align: center; margin: 0 0 var(--spacing-8); } -.a08-support__success { - margin: 0; - padding: var(--spacing-16); - text-align: center; - color: var(--color-success); - background-color: var(--color-mist-violet); - border-radius: var(--radius-cards); - font-size: var(--text-body-sm); -} - .a08-support__form { display: flex; flex-direction: column; gap: var(--spacing-16); } -.a08-support__textarea { - resize: vertical; - min-height: 120px; - font-family: inherit; -} .a08-support__submit { width: 100%; margin-top: var(--spacing-8); diff --git a/A09_Security/A09_Security_UI_Page.ts b/A09_Security/A09_Security_UI_Page.ts index d4c028ae6..814893194 100644 --- a/A09_Security/A09_Security_UI_Page.ts +++ b/A09_Security/A09_Security_UI_Page.ts @@ -1,3 +1,4 @@ +import { createCard } from "@ui/ui_template_elements"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { SECURITY_TERMS_VERSION, securityTerms } from "./A09_Security_Terms"; import "./A09_Security_UI_Style.css"; @@ -5,13 +6,11 @@ import "./A09_Security_UI_Style.css"; const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex]; export function renderA09Security(root: HTMLElement): void { - const page = document.createElement("article"); - page.className = "a09-security"; const title = document.createElement("h1"); title.textContent = L("A09_Security_Title"); const version = document.createElement("p"); version.textContent = `${L("A09_Security_Version")} ${SECURITY_TERMS_VERSION}`; - page.append(title, version); + const blocks: HTMLElement[] = [title, version]; const sections = [ ["A09_Security_Terms", securityTerms.terms], ["A09_Security_Privacy", securityTerms.privacy], @@ -24,7 +23,9 @@ export function renderA09Security(root: HTMLElement): void { const paragraph = document.createElement("p"); paragraph.textContent = body[currentLanguageIndex]; section.append(heading, paragraph); - page.append(section); + blocks.push(section); } + const page = createCard({ body: blocks }); + page.classList.add("a09-security"); root.append(page); } diff --git a/A09_Security/A09_Security_UI_Style.css b/A09_Security/A09_Security_UI_Style.css index 4b92c170b..d130ac6d2 100644 --- a/A09_Security/A09_Security_UI_Style.css +++ b/A09_Security/A09_Security_UI_Style.css @@ -1,12 +1,13 @@ +/* 약관 문서 — 카드 겉은 공용 부품(카드) · 여기는 폭 · 여백 · 글 흐름만 */ .a09-security { max-width: 800px; margin: var(--spacing-64) auto; padding: var(--spacing-40); color: var(--color-text); - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - box-shadow: var(--shadow-sm); +} + +.a09-security > .ui-card__body { + display: block; } .a09-security section { diff --git a/B01_Dashboard/B01_Dashboard_UI_PageKit.ts b/B01_Dashboard/B01_Dashboard_UI_PageKit.ts index 8982c2417..2877dd00b 100644 --- a/B01_Dashboard/B01_Dashboard_UI_PageKit.ts +++ b/B01_Dashboard/B01_Dashboard_UI_PageKit.ts @@ -5,7 +5,7 @@ * 아이콘 = `ui_template_icons`(552-2 에 여섯을 옮김). * ========================================================================== */ -import { createButton, createTag, showConfirmDialog } from "@ui/ui_template_elements"; +import { createButton, createTag } from "@ui/ui_template_elements"; import { createEmpty } from "@ui/ui_template_empty"; import { icon, type IconName } from "@ui/ui_template_icons"; import type { BadgeTone } from "./logic/B01_Dashboard_Logic_Pages"; @@ -70,107 +70,16 @@ export function pageFrame(host: HTMLElement, title: string, desc: string): PageF }; } -export type Cell = string | Node; - -export interface RowMenuItem { - label: string; - onClick: () => void; - /** 삭제 · 빼기 = 빨강 + 확인창 */ - danger?: boolean; - /** 확인창 글(danger 일 때) */ - confirm?: string; - /** 누르면 열리는 모달이 확인을 맡음 — 빨강만 · 확인창 없음 */ - ownConfirm?: boolean; -} - -/** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · danger 는 확인 뒤 실행 */ -export function rowMenu(items: RowMenuItem[]): HTMLElement { - const wrap = node("div", "b01-pg-menu"); - if (!items.length) return wrap; - const trigger = node("button", "b01-pg-menu__btn"); - trigger.type = "button"; - trigger.title = trigger.ariaLabel = "더 보기"; - trigger.setAttribute("aria-haspopup", "menu"); - trigger.setAttribute("aria-expanded", "false"); - trigger.append(kitIcon("dots", 18)); - const pop = node("div", "b01-pg-menu__pop"); - pop.setAttribute("role", "menu"); - pop.hidden = true; - const setOpen = (open: boolean): void => { - pop.hidden = !open; - trigger.setAttribute("aria-expanded", String(open)); - wrap.classList.toggle("is-open", open); - }; - for (const item of items) { - const b = node("button", `b01-pg-menu__item${item.danger ? " is-danger" : ""}`, item.label); - b.type = "button"; - b.setAttribute("role", "menuitem"); - b.addEventListener("click", () => { - setOpen(false); - if (!item.danger || item.ownConfirm) return item.onClick(); - void showConfirmDialog(item.confirm ?? `${item.label} 하시겠습니까?`, item.label).then( - (ok) => ok && item.onClick(), - ); - }); - pop.append(b); - } - trigger.addEventListener("click", (e) => { - e.stopPropagation(); - setOpen(pop.hidden === true); - }); - const outside = (e: MouseEvent): void => { - if (!wrap.isConnected) document.removeEventListener("click", outside); - else if (!wrap.contains(e.target as Node)) setOpen(false); - }; - document.addEventListener("click", outside); - wrap.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); - wrap.append(trigger, pop); - return wrap; -} - -export interface Column { - label: string; - /** 칸 너비(CSS) — 없으면 나눠 가짐 */ - width?: string; - align?: "end"; -} - -/** 표 껍데기 — 머리 + 빈 tbody (줄은 호출 쪽이 채움 · 펼침 줄 같은 것을 끼우려고 떼어 둠) */ -export function tableShell(columns: Column[]): { root: HTMLElement; tbody: HTMLElement } { - const table = node("table", "b01-pg-table"); - const head = node("thead", ""); - const hr = node("tr", ""); - for (const col of columns) { - const th = node("th", col.align ? "is-end" : "", col.label); - if (col.width) th.style.width = col.width; - hr.append(th); - } - head.append(hr); - const tbody = node("tbody", ""); - table.append(head, tbody); - const root = node("div", "b01-pg-card b01-pg-scroll"); - root.append(table); - return { root, tbody }; -} - -/** 한 줄 — 칸 수는 columns 와 같게 · label "" 인 칸 = ⋯ 칸 */ -export function tableRow(columns: Column[], cells: Cell[]): HTMLElement { - const tr = node("tr", ""); - cells.forEach((cell, i) => { - const col = columns[i]; - const td = node("td", col?.align ? "is-end" : col?.label === "" ? "b01-pg-menucell" : ""); - td.append(cell); - tr.append(td); - }); - return tr; -} - -/** 표 — 줄 높이 40 · 세로선 없음 · 마지막 칸 = ⋯ (columns 에 label "" 로) */ -export function dataTable(columns: Column[], rows: Cell[][]): HTMLElement { - const { root, tbody } = tableShell(columns); - tbody.append(...rows.map((cells) => tableRow(columns, cells))); - return root; -} +// 부품(552-3) — 표 · 줄 끝 ⋯ · 설정 줄 = ui_template 부품 · 부름 꼴 그대로 +export { createRowMenu as rowMenu, type RowMenuItem } from "@ui/ui_template_context_menu"; +export { + createTable as dataTable, + tableRow, + tableShell, + type Cell, + type Column, +} from "@ui/ui_template_table"; +export { settingCard, settingRow, valueText } from "@ui/ui_template_setting_row"; export interface EmptyOptions { icon: IconName | KitIconName; @@ -235,22 +144,6 @@ export function tabBar( }; } -/** 설정 카드 한 줄(Linear 꼴) — 제목 · 설명 왼쪽 · 값 · 조작 오른쪽 */ -export function settingRow(title: string, desc: string, right: Node): HTMLElement { - const row = node("div", "b01-pg-row"); - const left = node("div", "b01-pg-row__text"); - left.append(node("strong", "", title)); - if (desc) left.append(node("span", "b01-pg-row__desc", desc)); - const side = node("div", "b01-pg-row__side"); - side.append(right); - row.append(left, side); - return row; -} - -/** 값 글 — 없으면 「-」 */ -export const valueText = (v: string | number | null | undefined): HTMLElement => - node("span", "b01-pg-row__value", v === null || v === undefined || v === "" ? "-" : String(v)); - /** 줄 첫 칸 — 이름 + (작은 글) 보조 */ export function nameCell(name: string, sub?: string | null): HTMLElement { const cell = node("div", "b01-pg-name"); @@ -258,10 +151,3 @@ export function nameCell(name: string, sub?: string | null): HTMLElement { if (sub) cell.append(node("span", "b01-pg-name__sub", sub)); return cell; } - -/** 카드 묶음 — 설정 줄을 둥근 상자 한 개에 */ -export function settingCard(rows: HTMLElement[]): HTMLElement { - const card = node("div", "b01-pg-card b01-pg-settings"); - card.append(...rows); - return card; -} diff --git a/B01_Dashboard/B01_Dashboard_UI_Profile.ts b/B01_Dashboard/B01_Dashboard_UI_Profile.ts index 474dfdab7..c894a03e6 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Profile.ts +++ b/B01_Dashboard/B01_Dashboard_UI_Profile.ts @@ -31,7 +31,8 @@ function input(value: string, type = "text", label = ""): HTMLInputElement { /** 입력 줄 아래에 오류 글 — 비우면 지움 */ function fieldError(row: HTMLElement, message?: string): void { row.querySelector(".b01-sys-error")?.remove(); - if (message) row.querySelector(".b01-pg-row__text")?.append(el("span", "b01-sys-error", message)); + if (message) + row.querySelector(".ui-setting-row__text")?.append(el("span", "b01-sys-error", message)); } export function buildProfileCard(user: DashboardUser): HTMLElement { diff --git a/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css b/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css index 98ad71bd5..e7c68b51e 100644 --- a/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css +++ b/B01_Dashboard/B01_Dashboard_UI_Style_Pages.css @@ -45,53 +45,6 @@ border: 1px solid var(--b01-line); border-radius: var(--b01-radius); } -.b01-pg-scroll { - overflow-x: auto; -} -.b01-pg-table { - width: 100%; - border-collapse: collapse; - font-size: 13px; -} -.b01-pg-table th { - height: 36px; - padding: 0 12px; - text-align: left; - font-weight: 600; - font-size: 12px; - color: var(--b01-muted); - border-bottom: 1px solid var(--b01-line); - white-space: nowrap; -} -.b01-pg-table td { - height: 40px; - padding: 0 12px; - border-bottom: 1px solid var(--b01-line); - vertical-align: middle; -} -.b01-pg-table tbody tr:last-child td { - border-bottom: 0; -} -.b01-pg-table tbody tr:hover { - background: var(--b01-hover); -} -.b01-pg-table .is-end { - text-align: right; -} -.b01-pg-table td.b01-pg-menucell { - width: 44px; - padding: 0 8px; - text-align: right; -} -.b01-pg-table tr.is-sub > td { - height: auto; - padding: 0; - background: var(--b01-bg); -} -.b01-pg-table tr.is-sub:hover { - background: var(--b01-bg); -} - /* 이름 칸 */ .b01-pg-name { display: flex; @@ -127,60 +80,6 @@ white-space: nowrap; } -/* 줄 끝 ⋯ */ -.b01-pg-menu { - position: relative; - display: inline-block; -} -.b01-pg-menu__btn { - display: grid; - place-items: center; - width: 28px; - height: 28px; - border: 0; - border-radius: 6px; - background: none; - color: var(--b01-muted); - cursor: pointer; -} -.b01-pg-menu__btn:hover, -.b01-pg-menu.is-open .b01-pg-menu__btn { - background: var(--b01-hover); - color: var(--b01-text); -} -.b01-pg-menu__pop { - position: absolute; - right: 0; - top: 30px; - z-index: 20; - min-width: 132px; - padding: 4px; - background: var(--b01-card); - border: 1px solid var(--b01-line); - border-radius: 8px; - box-shadow: 0 6px 18px rgb(0 0 0 / 12%); - text-align: left; -} -.b01-pg-menu__item { - display: block; - width: 100%; - padding: 7px 10px; - border: 0; - border-radius: 6px; - background: none; - color: var(--b01-text); - font: inherit; - text-align: left; - cursor: pointer; - white-space: nowrap; -} -.b01-pg-menu__item:hover { - background: var(--b01-hover); -} -.b01-pg-menu__item.is-danger { - color: var(--b01-alert); -} - /* 탭 */ .b01-pg-tabs { display: flex; @@ -222,46 +121,6 @@ display: none; } -/* 설정 줄(Linear 꼴) */ -.b01-pg-settings { - overflow: hidden; -} -.b01-pg-row { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - min-height: 52px; - padding: 8px 16px; - border-bottom: 1px solid var(--b01-line); -} -.b01-pg-row:last-child { - border-bottom: 0; -} -.b01-pg-row__text { - display: flex; - flex-direction: column; - min-width: 0; -} -.b01-pg-row__desc { - font-size: 12px; - color: var(--b01-muted); -} -.b01-pg-row__side { - display: flex; - align-items: center; - gap: 8px; - flex: none; - max-width: 55%; -} -.b01-pg-row__side .ui-field__label:empty { - display: none; -} -.b01-pg-row__value { - text-align: right; - overflow-wrap: anywhere; -} - .b01-pg-link { border: 0; background: none; @@ -460,7 +319,7 @@ outline: 2px solid var(--b01-accent); outline-offset: 0; } -.b01-pg-row__side .b01-sys-input { +.ui-setting-row__side .b01-sys-input { width: 240px; } .b01-sys-count { diff --git a/B10_Payment/B10_Payment_UI_Page.ts b/B10_Payment/B10_Payment_UI_Page.ts index 3a6ddb7ef..973278451 100644 --- a/B10_Payment/B10_Payment_UI_Page.ts +++ b/B10_Payment/B10_Payment_UI_Page.ts @@ -1,4 +1,6 @@ -import { createButton, createTag } from "@ui/ui_template_elements"; +import { createBadge } from "@ui/ui_template_badge"; +import { createBanner } from "@ui/ui_template_banner"; +import { createButton } from "@ui/ui_template_elements"; import { section, table, text } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; @@ -13,15 +15,13 @@ export function renderB10Payment(root: HTMLElement): void { invoiceBody.className = "b10-payment__summary"; const invoiceDescription = document.createElement("p"); invoiceDescription.textContent = L("B10_Payment_Invoice_Description"); - invoiceBody.append(invoiceDescription, createTag(L("B10_Payment_Invoice_Status"), "neutral")); + invoiceBody.append(invoiceDescription, createBadge(L("B10_Payment_Invoice_Status"), "neutral")); const invoiceSection = section(L("B10_Payment_Invoice_Title"), invoiceBody, true, [ createButton({ label: L("B10_Payment_Invoice_Request"), disabled: true }), ]); - const depositNote = document.createElement("p"); - depositNote.className = "b10-payment__note"; - depositNote.textContent = L("B10_Payment_Deposit_Note"); + const depositNote = createBanner({ tone: "info", text: L("B10_Payment_Deposit_Note") }); const depositBody = document.createElement("div"); depositBody.className = "b10-payment__summary"; depositBody.append( diff --git a/B10_Payment/B10_Payment_UI_Style.css b/B10_Payment/B10_Payment_UI_Style.css index 76d5a4378..c016deb8a 100644 --- a/B10_Payment/B10_Payment_UI_Style.css +++ b/B10_Payment/B10_Payment_UI_Style.css @@ -4,11 +4,3 @@ gap: var(--spacing-16); color: var(--color-text-body); } - -.b10-payment__note { - padding: var(--spacing-16); - border-radius: var(--radius-cards); - background: var(--color-surface); - color: var(--color-text-secondary); - font-size: var(--text-body-sm); -} diff --git a/B11_Status/B11_Status_UI_Page.ts b/B11_Status/B11_Status_UI_Page.ts index b4f079f26..ec50067ab 100644 --- a/B11_Status/B11_Status_UI_Page.ts +++ b/B11_Status/B11_Status_UI_Page.ts @@ -1,4 +1,5 @@ -import { createButton, createTag } from "@ui/ui_template_elements"; +import { createBadge } from "@ui/ui_template_badge"; +import { createButton, createCard } from "@ui/ui_template_elements"; import { section } from "@ui/ui_template_general_blocks"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; @@ -9,8 +10,9 @@ function L(key: keyof typeof ui_locales): string { } function buildStatusFlow(): HTMLElement { - const flow = document.createElement("ol"); + const flow = document.createElement("div"); flow.className = "b11-status__flow"; + flow.setAttribute("role", "list"); const steps = [ L("B11_Status_Step_Request"), L("B11_Status_Step_Issue"), @@ -18,11 +20,14 @@ function buildStatusFlow(): HTMLElement { L("B11_Status_Step_Complete"), ]; for (const label of steps) { - const item = document.createElement("li"); - item.className = "b11-status__step"; const title = document.createElement("strong"); title.textContent = label; - item.append(title, createTag(L("B11_Status_NotStarted"), "neutral")); + const item = createCard({ + body: [title, createBadge(L("B11_Status_NotStarted"), "neutral")], + pad: 16, + }); + item.classList.add("b11-status__step"); + item.setAttribute("role", "listitem"); flow.append(item); } return flow; diff --git a/B11_Status/B11_Status_UI_Style.css b/B11_Status/B11_Status_UI_Style.css index 9d87977fe..03ca7bb8b 100644 --- a/B11_Status/B11_Status_UI_Style.css +++ b/B11_Status/B11_Status_UI_Style.css @@ -2,20 +2,10 @@ display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--spacing-16); - margin: 0; - padding: 0; - list-style: none; } -.b11-status__step { - display: flex; - flex-direction: column; +.b11-status__step > .ui-card__body { gap: var(--spacing-12, var(--spacing-8)); - padding: var(--spacing-16); - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background: var(--color-surface); - color: var(--color-text-body); } .b11-status__download { diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts index 49c819408..aad82e483 100644 --- a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -17,6 +17,7 @@ import { } from "@ui/ui_template_elements"; import { createGeneralLayout } from "@ui/ui_template_general_layout"; import { createHelpButton } from "@ui/ui_template_help"; +import { createText } from "@ui/ui_template_text"; import { t } from "@ui/ui_template_locale"; import { stationLabel } from "../common_util/common_util_station"; import { createGroupProject } from "../A00_Common/group_workflow"; @@ -70,8 +71,7 @@ export function renderD01PlanRegister(root: HTMLElement): void { const endM = createInputField({ label: t("DE_Reg_EndM"), type: "number", min: 0 }); // 누가거리 칸 옆 즉시 측점 표기(저장값 · 서버 검사는 m 그대로) for (const field of [startM, endM]) { - const shown = document.createElement("span"); - shown.style.cssText = "font-size:12px;color:var(--color-text-muted)"; + const shown = createText("", "muted"); shown.dataset.station = ""; field.root.insertBefore(shown, field.root.lastElementChild); field.input.addEventListener("input", () => { diff --git a/D02_PlanBase/D02_PlanBase_UI_Page.ts b/D02_PlanBase/D02_PlanBase_UI_Page.ts index 779805682..d4e9c89fb 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Page.ts +++ b/D02_PlanBase/D02_PlanBase_UI_Page.ts @@ -11,12 +11,14 @@ import "./D02_PlanBase_UI_Style.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { createButton, + createInputField, el, hideLoadingOverlay, showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; import { createPanelCard } from "@ui/ui_template_collapsible"; +import { createText } from "@ui/ui_template_text"; import { editMap, type MapPin } from "@ui/ui_template_kakao_map"; import { t } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; @@ -66,9 +68,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; const mapHost = el("div", { className: "d02-map" }); const table = el("div", { className: "d02-points" }); - const info = el("p", { className: "d02-info" }); + const info = createText("", "muted"); + info.classList.add("d02-info"); const card = createPanelCard(t("D02_Points_Title"), false); - const pointsHint = el("p", { className: "d02-info", text: t("D02_Points_Hint") }); + const pointsHint = createText(t("D02_Points_Hint"), "muted"); + pointsHint.classList.add("d02-info"); card.append(pointsHint, table, info); const save = createButton({ label: t("D02_Save"), @@ -139,12 +143,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { function drawTable(): void { const rows = points.map((p, i) => { - const lon = el("input", { className: "d02-points__input" }) as HTMLInputElement; - const lat = el("input", { className: "d02-points__input" }) as HTMLInputElement; - lon.type = lat.type = "number"; + const lon = createInputField({ type: "number", placeholder: t("D02_Lon"), size: 24 }).input; + const lat = createInputField({ type: "number", placeholder: t("D02_Lat"), size: 24 }).input; + lon.classList.add("d02-points__input"); + lat.classList.add("d02-points__input"); lon.step = lat.step = "any"; - lon.placeholder = t("D02_Lon"); - lat.placeholder = t("D02_Lat"); lon.value = p ? String(p.lon) : ""; lat.value = p ? String(p.lat) : ""; lon.disabled = lat.disabled = !editable; @@ -154,8 +157,11 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { const typed = (): void => { const at = { lon: Number.parseFloat(lon.value), lat: Number.parseFloat(lat.value) }; const ok = at.lon >= 120 && at.lon <= 135 && at.lat >= 30 && at.lat <= 45; - lon.classList.toggle("is-invalid", !ok); - lat.classList.toggle("is-invalid", !ok); + for (const box of [lon, lat]) { + box.classList.toggle("is-error", !ok); + if (ok) box.removeAttribute("aria-invalid"); + else box.setAttribute("aria-invalid", "true"); + } if (ok) place(i, at); }; lon.addEventListener("change", typed); @@ -254,8 +260,6 @@ export async function renderD02PlanBase(root: HTMLElement): Promise { active = firstEmpty(); paint(); } catch (error) { - table.replaceChildren( - el("p", { className: "d02-info", text: error instanceof Error ? error.message : "" }), - ); + table.replaceChildren(createText(error instanceof Error ? error.message : "", "muted")); } } diff --git a/D02_PlanBase/D02_PlanBase_UI_Style.css b/D02_PlanBase/D02_PlanBase_UI_Style.css index ff48d662e..39cf82ad8 100644 --- a/D02_PlanBase/D02_PlanBase_UI_Style.css +++ b/D02_PlanBase/D02_PlanBase_UI_Style.css @@ -35,17 +35,5 @@ } .d02-points__input { - width: 100%; min-width: 0; - font-size: var(--text-caption); -} - -.d02-points__input.is-invalid { - outline: 1px solid var(--color-danger, #d92d20); -} - -.d02-info { - margin: 0; - color: var(--color-text-secondary); - font-size: var(--text-caption); } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts index 4cdb60430..49a9d99f0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts @@ -7,6 +7,8 @@ * ========================================================================== */ import "../B05_Profile/B05_Profile_UI_Style.css"; +import { createCheckbox } from "@ui/ui_template_checkbox"; +import { createSelectField } from "@ui/ui_template_input"; import { openModal } from "@ui/ui_template_modal"; import { createProgressCircle } from "@ui/ui_template_progress"; import { showToast } from "@ui/ui_template_elements"; @@ -51,17 +53,19 @@ function gridBounds(grid: DtmGrid): { export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: number): void { if (!years.length) return void showToast("3D 로 볼 연도 노선이 없습니다.", "warning"); let viewer: RouteViewer | null = createRouteViewer(); - const pick = document.createElement("select"); - pick.className = "d03-plan__3d-year"; - for (const item of years) - pick.append(new Option(`${item.year}년`, String(item.year), false, item.year === firstYear)); - const corridorBox = document.createElement("label"); - corridorBox.className = "d03-plan__3d-corridor"; - const corridorOn = Object.assign(document.createElement("input"), { - type: "checkbox", + const pick = createSelectField({ + options: years.map((item) => ({ value: String(item.year), text: `${item.year}년` })), + value: String(firstYear), + size: 28, + ariaLabel: "연도", + }).select; + pick.classList.add("d03-plan__3d-year"); + const corridor = createCheckbox(" 예상 코리도(표준 횡단 기본값 · 계획고 = 지반고)", { checked: true, }); - corridorBox.append(corridorOn, " 예상 코리도(표준 횡단 기본값 · 계획고 = 지반고)"); + const corridorBox = corridor.root; + corridorBox.classList.add("d03-plan__3d-corridor"); + const corridorOn = corridor.input; corridorOn.addEventListener("change", () => viewer?.setCorridorVisible(corridorOn.checked)); const top = document.createElement("div"); top.className = "d03-plan__3d-top"; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts index 2f2c41eee..1232ffa41 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts @@ -8,6 +8,7 @@ import "../B05_Profile/B05_Profile_UI_Style_RouteEdit.css"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; +import { createText } from "@ui/ui_template_text"; import { stationLabel } from "../common_util/common_util_station"; import type { DtmGrid } from "../common_util/common_util_surface_sampler"; import { buildCrossSvg } from "../B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw"; @@ -63,7 +64,7 @@ export function createPlanCross(): PlanCross { const clear = (text: string): void => { head.textContent = ""; - body.replaceChildren(Object.assign(document.createElement("p"), { textContent: text })); + body.replaceChildren(createText(text, "muted", 14)); delete root.dataset.chainage; }; clear("평면에서 측점을 누르면 그 자리 지반선 · 예상 횡단이 보입니다."); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index dbad0a984..185ed67b9 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -15,7 +15,10 @@ import "./D03_PlanAnalysis_UI_Style.css"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { createCard } from "@ui/ui_template_card"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { createProgressBar } from "@ui/ui_template_progress"; +import { createText } from "@ui/ui_template_text"; import { createButton, hideLoadingOverlay, @@ -118,12 +121,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { main.className = "d03-plan"; main.append(map.root, cross.root); - const terrainText = document.createElement("p"); - terrainText.className = "d03-plan__hint"; - const bar = document.createElement("progress"); - bar.className = "d03-plan__bar"; - bar.max = 100; - bar.hidden = true; + const terrainText = createText("", "muted"); + terrainText.classList.add("d03-plan__hint"); + const bar = createProgressBar(); + bar.root.hidden = true; const more = createButton({ label: "범위 밖 — 더 받기", variant: "filled", @@ -145,7 +146,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { actions.className = "ui-sidebar-actions"; actions.append(analyzeButton, saveButton, viewButton, undoButton); const terrainCard = createPanelCard("지형", false); - terrainCard.append(terrainText, bar, more); + terrainCard.append(terrainText, bar.root, more); const routeCard = createPanelCard("연도별 노선", false); routeCard.append(yearList); const panel = document.createElement("div"); @@ -334,29 +335,33 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { for (const mode of PLAN_MODES) { const route = result.routes[mode]; if (!route) continue; - const card = document.createElement("button"); - card.type = "button"; - card.className = "d03-plan__mode" + (row.mode === mode ? " is-picked" : ""); - card.dataset.mode = mode; const metrics = route.metrics; + const values = createText( + !metrics + ? "노선 없음" + : metrics.max_grade_pct === undefined + ? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈` + : `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`, + "muted", + ); + const card = createCard({ + title: MODE_LABEL[mode], + body: [values], + pad: 12, + selected: row.mode === mode, + onSelect: () => { + if (row.mode === mode) return; + remember(); + choices = { ...choices, [result.year]: mode }; + keepDraft(); + refresh(); + }, + }); + card.classList.add("d03-plan__mode"); + card.classList.toggle("is-picked", row.mode === mode); + card.dataset.mode = mode; card.disabled = !!route.error || !metrics; card.title = route.error ?? ""; - const name = document.createElement("strong"); - name.textContent = MODE_LABEL[mode]; - const values = document.createElement("span"); - values.textContent = !metrics - ? "노선 없음" - : metrics.max_grade_pct === undefined - ? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈` - : `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`; - card.append(name, values); - card.addEventListener("click", () => { - if (row.mode === mode) return; - remember(); - choices = { ...choices, [result.year]: mode }; - keepDraft(); - refresh(); - }); box.append(card); } return box; @@ -526,13 +531,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { /** 뒤에서 도는 일을 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ async function watch(kind: "terrain" | "analyze"): Promise { busy = kind; - bar.hidden = false; + bar.root.hidden = false; refresh(); try { for (;;) { if (!root.isConnected) return null; const job = (await fetchPlanProgress(projectId))[kind]; - bar.value = job.percent; + bar.set(job.percent / 100); terrainText.textContent = `${kind === "terrain" ? "지형 받기" : "분석"} ${job.percent}% · ${job.message ?? ""}`; if (!job.running) { if (job.error) showToast(job.error, "error"); @@ -546,7 +551,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { return null; } finally { busy = null; - bar.hidden = true; + bar.root.hidden = true; refresh(); } } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index fc5773e7a..234fe200b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -63,17 +63,10 @@ .d03-plan__cross-body p { margin: 12px 0; - color: var(--color-text-secondary); } .d03-plan__hint { margin: 0 0 6px; - font-size: 0.85rem; - color: var(--color-text-secondary); -} - -.d03-plan__bar { - width: 100%; } .d03-plan__years { @@ -93,37 +86,10 @@ font-size: 0.85rem; } -.d03-plan__mode { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 2px; - padding: 6px 8px; - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - background: var(--color-surface); - color: var(--color-text); - font-size: 0.8rem; - text-align: left; - cursor: pointer; -} - -.d03-plan__mode.is-picked { - border-color: var(--color-primary); - box-shadow: inset 0 0 0 1px var(--color-primary); -} - -.d03-plan__mode:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.d03-plan__mode span { - color: var(--color-text-secondary); -} - .d03-plan__3d-top { display: flex; + gap: var(--spacing-8); + align-items: center; justify-content: flex-end; margin-bottom: 6px; } diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts index 6a3ee8416..654cc0f3b 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts @@ -14,6 +14,7 @@ import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_fro import { kstText } from "../common_util/common_util_time_kst"; import { createButton, + createInputField, createTag, el, hideLoadingOverlay, @@ -21,7 +22,10 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createBanner } from "@ui/ui_template_banner"; +import { createCheckbox } from "@ui/ui_template_checkbox"; import { createPanelCard } from "@ui/ui_template_collapsible"; +import { tableRow, tableShell, type Column } from "@ui/ui_template_table"; import { t } from "@ui/ui_template_locale"; import { stationLabel } from "../common_util/common_util_station"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; @@ -146,14 +150,11 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { const listId = `d04-companies-${year}`; const options = el("datalist", { attrs: { id: listId } }); const found = new Map(); - const firm = el("input", { - className: "d04-confirm__input", - attrs: { list: listId, placeholder: t("D04_Company") }, - }); - const mail = el("input", { - className: "d04-confirm__input", - attrs: { type: "email", placeholder: t("D04_Email") }, - }); + const firm = createInputField({ placeholder: t("D04_Company"), size: 28 }).input; + firm.setAttribute("list", listId); + const mail = createInputField({ type: "email", placeholder: t("D04_Email"), size: 28 }).input; + firm.classList.add("d04-confirm__input"); + mail.classList.add("d04-confirm__input"); firm.addEventListener("input", () => { const query = firm.value.trim(); if (!query) return; @@ -212,51 +213,51 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise { function draw(): void { const list = current(); - const head = el("tr", { - children: [ - "D04_Col_Year", - "D04_Col_Length", - "D04_Col_Start", - "D04_Col_End", - "D04_Col_Chainage", - "D04_Col_Confirmed", - "D04_Col_Assignees", - ].map((key) => el("th", { text: t(key as Parameters[0]) })), - }); - const rows = summary.years.map((row) => { + const columns: Column[] = [ + "D04_Col_Year", + "D04_Col_Length", + "D04_Col_Start", + "D04_Col_End", + "D04_Col_Chainage", + "D04_Col_Confirmed", + "D04_Col_Assignees", + ].map((key, i) => ({ + label: t(key as Parameters[0]), + ...(i === 1 ? { align: "end" as const } : {}), + })); + const { root: tableRoot, tbody } = tableShell(columns); + tableRoot.querySelector("table")?.classList.add("d04-confirm__table"); + for (const row of summary.years) { const firms = list.filter((item) => item.year === row.year).map(firmChip); const chain = row.saved ? `${station(row.start_chainage_m)} ~ ${station(row.end_chainage_m)}` : t("D04_NotSaved"); - return el("tr", { - attrs: { "data-year": String(row.year) }, - children: [ - el("td", { text: String(row.year) }), - el("td", { text: fmt(row.length_m), attrs: { "data-num": "" } }), - el("td", { text: where(row.start) }), - el("td", { text: where(row.end) }), - el("td", { text: chain }), - el("td", { text: kstText(row.confirmed_at) }), - el("td", { children: editable ? [...firms, addForm(row.year)] : firms }), - ], - }); - }); - const table = el("table", { - className: "d04-confirm__table", - children: [el("thead", { children: [head] }), el("tbody", { children: rows })], - }); - const notes = draft() - ? [el("p", { className: "d04-confirm__dirty", text: t("D04_Dirty") })] - : []; - main.replaceChildren(el("h2", { text: summary.name }), ...notes, table); + const tr = tableRow(columns, [ + String(row.year), + fmt(row.length_m), + where(row.start), + where(row.end), + chain, + kstText(row.confirmed_at), + el("div", { children: editable ? [...firms, addForm(row.year)] : firms }), + ]); + tr.dataset.year = String(row.year); + tbody.append(tr); + } + const notes = draft() ? [createBanner({ tone: "warning", text: t("D04_Dirty") })] : []; + notes[0]?.classList.add("d04-confirm__dirty"); + main.replaceChildren(el("h2", { text: summary.name }), ...notes, tableRoot); pickList.replaceChildren( ...summary.years .filter((row) => row.saved) .map((row) => { - const box = el("input", { attrs: { type: "checkbox", value: String(row.year) } }); - box.checked = !row.confirmed_at; - return el("label", { className: "d04-confirm__pick", children: [box, ` ${row.year}`] }); + const pick = createCheckbox(String(row.year), { + checked: !row.confirmed_at, + value: String(row.year), + }); + pick.root.classList.add("d04-confirm__pick"); + return pick.root; }), ); } diff --git a/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css b/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css index 05556bae3..53c4fb41b 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css +++ b/D04_PlanConfirm/D04_PlanConfirm_UI_Style.css @@ -10,29 +10,6 @@ overflow: auto; } -.d04-confirm__table { - border-collapse: collapse; - font-size: var(--text-body-sm); - color: var(--color-text-body); -} - -.d04-confirm__table th, -.d04-confirm__table td { - padding: var(--spacing-8); - border: 1px solid var(--color-border); - text-align: left; - vertical-align: top; -} - -.d04-confirm__table thead th { - background: var(--color-surface); -} - -.d04-confirm__table td[data-num] { - text-align: right; - font-variant-numeric: tabular-nums; -} - .d04-confirm__firm, .d04-confirm__add { display: flex; @@ -44,15 +21,6 @@ .d04-confirm__input { min-width: 10rem; - padding: var(--spacing-4) var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-canvas); - color: var(--color-text-body); -} - -.d04-confirm__dirty { - color: var(--color-warning, var(--color-text-secondary)); } .d04-confirm__picks { 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..bade5d2fd 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 { @@ -51,38 +41,11 @@ gap: var(--spacing-4); } -.e02-table { - width: 100%; - border-collapse: collapse; - font-size: var(--text-body-sm); - color: var(--color-text-body); -} - -.e02-table th { - padding: var(--spacing-4); - border-bottom: 1px solid var(--color-border); - color: var(--color-text-secondary); - font-weight: var(--font-weight-medium); - text-align: left; - white-space: nowrap; -} - -.e02-table td { - padding: 2px var(--spacing-4); - vertical-align: middle; -} - +/* 표 모양은 표 부품 — 여기는 읽기 표 줄 안 줄바꿈만 */ .e02-table--read td { - padding: 2px var(--spacing-8); - border-bottom: 1px solid var(--color-border); white-space: nowrap; } -.e02-table--read tr.is-zoned td { - background: var(--color-mist-violet); - color: var(--color-text); -} - .e02-stations { max-height: 320px; overflow: auto; @@ -103,11 +66,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); @@ -127,30 +85,9 @@ font-size: var(--text-body-sm); } -.e02-field { - display: flex; - flex-direction: column; - gap: 2px; - font-size: var(--text-caption); - 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..2c352bc21 100644 --- a/E02_StabGround/E02_StabGround_UI_Tables.ts +++ b/E02_StabGround/E02_StabGround_UI_Tables.ts @@ -6,8 +6,17 @@ * 값 바꿈 = 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 { createTable } from "@ui/ui_template_table"; +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 +41,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 +62,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 +87,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 +105,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,21 +117,18 @@ 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 { - const node = el("table", { className: "e02-table" }) as HTMLTableElement; - const tr = node.createTHead().insertRow(); - for (const text of headers) tr.append(el("th", { text })); - const body = node.createTBody(); - for (const cells of rows) { - const row = body.insertRow(); - for (const cell of cells) row.insertCell().append(cell); - } - return node; +/** 표 부품(데이터표) — 머리 글 "" = 줄 끝 단추 칸 · 돌려주는 것 = 표 감싸개(속 `table.e02-table`) */ +export function table(headers: string[], rows: HTMLElement[][]): HTMLElement { + const wrap = createTable( + headers.map((label) => ({ label })), + rows, + { density: "data" }, + ); + wrap.querySelector("table")?.classList.add("e02-table"); + return wrap; } function defaultMaterial(view: E02View, id: string): Material | undefined { @@ -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; @@ -195,9 +200,9 @@ function materialsCard(view: E02View): HTMLElement { const mapRow = el("div", { className: "e02-map" }); mapRow.append(el("span", { className: "e02-map__title", text: t("E02_LabelMap") })); for (const label of LABELS) { - const field = el("label", { className: "e02-field" }); + const field = el("label", { className: "ui-field e02-field" }); field.append( - el("span", { text: L(`E02_Label_${label}`) }), + el("span", { className: "ui-field__label", text: L(`E02_Label_${label}`) }), selectInput(view, `label_map.${label}`, layerOptions(g), g.label_map[label], (v) => { g.label_map[label] = v; view.rebuild(); @@ -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)); @@ -324,15 +327,18 @@ function zonesCard(view: E02View): { section: HTMLElement; refresh: () => void } el("span", { text: fmt(r.soil_thickness_m) }), ]), ); - resolved.forEach((r, i) => node.tBodies[0].rows[i].classList.toggle("is-zoned", r.zoned)); - node.classList.add("e02-table--read"); + node.querySelectorAll("tbody tr").forEach((tr, i) => { + tr.classList.toggle("is-zoned", resolved[i].zoned); + tr.classList.toggle("is-active", resolved[i].zoned); + }); + node.querySelector("table")?.classList.add("e02-table--read"); stationsHost.append(node); }; refresh(); 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, @@ -390,7 +396,7 @@ function safetyCard(view: E02View): HTMLElement { ]); const { section, head } = card( "E02_Card_Safety", - table(["", ...SLOPES.map((s) => L(`E02_Slope_${s}`))], rows), + table(["\u00a0", ...SLOPES.map((s) => L(`E02_Slope_${s}`))], rows), ); headButtons( view, @@ -413,8 +419,8 @@ function loadsCard(view: E02View): HTMLElement { const g = view.ground; const d = view.defaults; const field = (label: string, input: HTMLElement): HTMLElement => { - const node = el("label", { className: "e02-field" }); - node.append(el("span", { text: label }), input); + const node = el("label", { className: "ui-field e02-field" }); + node.append(el("span", { className: "ui-field__label", text: label }), input); return node; }; const kh = numInput(view, "seismic.kh", g.seismic.kh, (v) => (g.seismic.kh = v as number), { diff --git a/E02_StabGround/E02_StabGround_UI_Water.ts b/E02_StabGround/E02_StabGround_UI_Water.ts index e613ae911..e2b8e720f 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, @@ -21,8 +23,8 @@ import { const L = (key: string): string => t(key as LocaleKey); function field(label: string, input: HTMLElement): HTMLElement { - const node = el("label", { className: "e02-field" }); - node.append(el("span", { text: label }), input); + const node = el("label", { className: "ui-field e02-field" }); + node.append(el("span", { className: "ui-field__label", text: label }), input); return node; } @@ -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/helper_130_b07_plan_manage.cjs b/resources/tester/helper_130_b07_plan_manage.cjs index 38666947f..2fddd1fbe 100644 --- a/resources/tester/helper_130_b07_plan_manage.cjs +++ b/resources/tester/helper_130_b07_plan_manage.cjs @@ -236,6 +236,7 @@ const { loadTs } = makeLoader({ openDrawing: (item) => API.fetchDesignDrawing("p1", item.id), syncDrawings: (_, list) => calls.push(["sync", list.length]), }; + if (request.endsWith(".css")) return {}; // 부품 CSS(552-3) — 트랜스파일 안 함 if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); throw new Error(`stub 없음: ${request}`); }, diff --git a/resources/tester/helper_153_b07_cross_layout.cjs b/resources/tester/helper_153_b07_cross_layout.cjs index 5077e3c5c..fa5829c05 100644 --- a/resources/tester/helper_153_b07_cross_layout.cjs +++ b/resources/tester/helper_153_b07_cross_layout.cjs @@ -94,6 +94,7 @@ const { loadTs } = makeLoader({ currentLanguageIndex: 0, ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }), }; + if (request.endsWith(".css")) return {}; // 부품 CSS(552-3) — 트랜스파일 안 함 if (request.startsWith(".")) { const target = path.join(path.dirname(full), `${request}.ts`); return fs.existsSync(target) ? loadTs(target) : {}; diff --git a/resources/tester/helper_280_5_b07_settings.cjs b/resources/tester/helper_280_5_b07_settings.cjs index e4fe87368..eb47a7b6c 100644 --- a/resources/tester/helper_280_5_b07_settings.cjs +++ b/resources/tester/helper_280_5_b07_settings.cjs @@ -206,6 +206,7 @@ const { loadTs } = makeLoader({ if (STUBS[request]) return STUBS[request]; if (request === "@ui/ui_template_collapsible") return loadTs(path.join(UI, "ui_template_collapsible.ts")); + if (request.endsWith(".css")) return {}; // 부품 CSS(552-3) — 트랜스파일 안 함 if (request.startsWith(".")) { const target = path.join(path.dirname(full), `${request}.ts`); return fs.existsSync(target) ? loadTs(target) : {}; diff --git a/resources/tester/helper_311_panel_folded.cjs b/resources/tester/helper_311_panel_folded.cjs index 1f07b89b8..bcb97353f 100644 --- a/resources/tester/helper_311_panel_folded.cjs +++ b/resources/tester/helper_311_panel_folded.cjs @@ -140,6 +140,7 @@ const { loadTs } = makeLoader({ if (request === "@ui/ui_template_collapsible") return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts")); if (request.endsWith(".css")) return {}; + if (request.endsWith(".css")) return {}; // 부품 CSS(552-3) — 트랜스파일 안 함 if (request.startsWith(".")) return loadTs(`${path.resolve(path.dirname(full), request)}.ts`); return require(request); }, diff --git a/resources/tester/test_290_1_project_purge.py b/resources/tester/test_290_1_project_purge.py index bc5e4311a..5b0e27f4f 100644 --- a/resources/tester/test_290_1_project_purge.py +++ b/resources/tester/test_290_1_project_purge.py @@ -60,5 +60,7 @@ def test_빈_초안은_고침_아님_취향은_둠(out): def test_공용_이동_한_곳에서_부름(): nav = (ROOT / "A00_Common/b_workflow_nav.ts").read_text(encoding="utf-8") assert "return enterProject(" in nav and "./b_project_switch" in nav - ui = (ROOT / "ui_template/ui_template_elements.ts").read_text(encoding="utf-8") - assert 'cancelLabel = "취소"' in ui and "label: cancelLabel" in ui + ui = (ROOT / "ui_template/ui_template_modal.ts").read_text( + encoding="utf-8" + ) # 확인창 = 부품(552-3) + assert 'cancelLabel = "취소"' in ui and "okLabel: confirmLabel, cancelLabel" in ui diff --git a/resources/tester/test_505_2_save_pick.py b/resources/tester/test_505_2_save_pick.py index 06fc40d80..af2974ee5 100644 --- a/resources/tester/test_505_2_save_pick.py +++ b/resources/tester/test_505_2_save_pick.py @@ -82,8 +82,8 @@ def test_1_가림막_저장도_도중_고침_검사() -> None: assert "const touched = !route && designSignature(ctx.projectId) !== sent;" in save assert "if (touched && locked)" in save and "저장 중 고친 값은 아직 저장 안 됨" in save assert "!locked && designSignature" not in save - elements = _read("ui_template/ui_template_elements.ts") - assert "keydown" not in elements[elements.index("function ensureOverlay") :][:1200] + toast = _read("ui_template/ui_template_toast.ts") # 가림막 = 부품 「알림」(552-3) + assert "keydown" not in toast[toast.index("function ensureOverlay") :][:1200] def test_8_저장_중_초기화_막음() -> None: diff --git a/resources/tester/test_552_3_banner.py b/resources/tester/test_552_3_banner.py index f3f7691eb..fd30f9f4f 100644 --- a/resources/tester/test_552_3_banner.py +++ b/resources/tester/test_552_3_banner.py @@ -22,4 +22,4 @@ def test_banner(): assert 'tone === "success" ? "✓ "' in ts # 색만으로 안 가름 for tone in ("warning", "danger", "success"): assert f".ui-banner--{tone}" in css - assert '"aria-label": "닫기"' in ts + assert 'createIconButton({ icon: "x", title: "닫기", size: 20 })' in ts # 단추 부품(552-2) diff --git a/resources/tester/test_552_3_drop.py b/resources/tester/test_552_3_drop.py new file mode 100644 index 000000000..83ce37c2f --- /dev/null +++ b/resources/tester/test_552_3_drop.py @@ -0,0 +1,20 @@ +"""552-3 부품 「파일 놓기 칸」 지킴 시험 — 끌어 옴(.is-over) · 고르기 단추(단추 부품 28) · 진행 · 오류 · 막힘.""" + +from test_552_3_card import _text, check_part + + +def test_drop(): + check_part( + "drop", + "ui_template_drop.ts", + ["ui_template_drop.css"], + ["createDrop"], + ["ui-drop", "ui-drop__text", "ui-drop__hint", "ui-drop__error", "is-over"], + ) + ts, css = _text("ui_template_drop.ts"), _text("ui_template_drop.css") + assert 'label: "파일 고르기"' in ts and "size: 28" in ts + assert 'input.value = ""' in ts # 같은 파일 다시 고름 + assert "createProgressBar({ thick: 6 })" in ts + assert '.ui-drop:is(.is-over, [data-state="hover"])' in css + assert '.ui-drop:is(.is-error, [data-state="error"])' in css + assert "min-height: 48px" in css diff --git a/resources/tester/test_552_3_modal.py b/resources/tester/test_552_3_modal.py new file mode 100644 index 000000000..7b2cd7172 --- /dev/null +++ b/resources/tester/test_552_3_modal.py @@ -0,0 +1,32 @@ +"""552-3 부품 「모달 · 확인창」 지킴 시험 — 폭 단 · 확인 · 위험 확인 · 글 묻기(prompt 대신) · 옛 부름 꼴 · 가림막 색.""" + +from test_552_3_card import _text, check_part + + +def test_modal(): + check_part( + "modal", + "ui_template_modal.ts", + ["ui_template_modal.css"], + ["openModal", "buildModal", "confirmPanel", "askConfirm", "askText", "showConfirmDialog"], + [ + "ui-modal", + "ui-modal__top", + "ui-modal__body", + "ui-modal--wide", + "ui-confirm", + "ui-confirm__input", + ], + ) + ts, css = _text("ui_template_modal.ts"), _text("ui_template_modal.css") + assert 'width?: "24rem" | "40rem" | "960px"' in ts + for w in ("24rem", "40rem", "960px"): + assert f'.ui-modal[data-width="{w}"]' in css + assert 'variant: opts.danger ? "danger" : "filled"' in ts + assert "r.ok ? r.text : null" in ts # 취소 = null(prompt 와 같음) + assert "background-color: var(--color-scrim);" in css and "rgba(" not in css + elements = _text("ui_template_elements.ts") + assert ( + 'export { askConfirm, askText, showConfirmDialog } from "./ui_template_modal";' in elements + ) + assert "export function showConfirmDialog" not in elements # 한 벌 diff --git a/resources/tester/test_552_3_row_menu.py b/resources/tester/test_552_3_row_menu.py new file mode 100644 index 000000000..b7fe5b5a0 --- /dev/null +++ b/resources/tester/test_552_3_row_menu.py @@ -0,0 +1,25 @@ +"""552-3 부품 「⋯ · 오른쪽 메뉴」 지킴 시험 — 아이콘 단추 28 · 위험 = 확인창 · 한 번에 하나 장부 · 대시보드 rowMenu 가 부품을 씀.""" + +from test_552_3_card import ROOT, _text + + +def test_row_menu(): + ts = _text("ui_template_context_menu.ts") + head = ts[ts.index("부품 「⋯ · 오른쪽 메뉴」") :].split("*/", 1)[0] + for word in ("꼴", "크기", "상태", "쓰는 법", "대신하는"): + assert word in head, word + assert "export function createRowMenu" in ts + assert 'createIconButton({ icon: "dots", title: "더 보기", size: 28 })' in ts + assert "if (open) enterMenu(entry);" in ts and "showConfirmDialog(" in ts + css = _text("ui_template_context_menu.css") + mine = css[css.index("(552-3)") :] + assert "rgb" not in mine and "#" not in mine and "--b01-" not in mine + assert '.ui-row-menu__item:is(:hover, [data-state="hover"]):not(:disabled)' in mine + assert ".ui-row-menu__item.is-danger" in mine and ".ui-row-menu__item:disabled" in mine + kit = (ROOT / "B01_Dashboard" / "B01_Dashboard_UI_PageKit.ts").read_text(encoding="utf-8") + assert "createRowMenu as rowMenu, type RowMenuItem" in kit + from test_552_3_card import kit_line + + line = kit_line("row-menu") + assert 'file: "ui_template_context_menu.ts"' in line and "demo:" in line + assert "legacy: true" not in line diff --git a/resources/tester/test_552_3_setting_row.py b/resources/tester/test_552_3_setting_row.py new file mode 100644 index 000000000..46d877c99 --- /dev/null +++ b/resources/tester/test_552_3_setting_row.py @@ -0,0 +1,22 @@ +"""552-3 부품 「설정 줄」 지킴 시험 — 줄 52 · 오른쪽 55 % · 빈 값 「-」 · 대시보드 settingRow 가 부품을 씀.""" + +from test_552_3_card import ROOT, _text, check_part + + +def test_setting_row(): + check_part( + "setting-row", + "ui_template_setting_row.ts", + ["ui_template_setting_row.css"], + ["settingRow", "settingCard"], + ["ui-setting-row", "ui-setting-row__text", "ui-setting-row__side", "ui-setting-card"], + ) + css = _text("ui_template_setting_row.css") + assert "min-height: 52px" in css and "max-width: 55%" in css + b01 = ROOT / "B01_Dashboard" + kit = (b01 / "B01_Dashboard_UI_PageKit.ts").read_text(encoding="utf-8") + assert ( + 'export { settingCard, settingRow, valueText } from "@ui/ui_template_setting_row";' in kit + ) + profile = (b01 / "B01_Dashboard_UI_Profile.ts").read_text(encoding="utf-8") + assert ".ui-setting-row__text" in profile and ".b01-pg-row" not in profile diff --git a/resources/tester/test_552_3_table.py b/resources/tester/test_552_3_table.py new file mode 100644 index 000000000..ff8bb6a68 --- /dev/null +++ b/resources/tester/test_552_3_table.py @@ -0,0 +1,23 @@ +"""552-3 부품 「표」 지킴 시험 — 일반 40 · 데이터 28 · ⋯ 칸 · 빈 줄 · 줄 상태 · 대시보드 dataTable 이 부품을 씀.""" + +from test_552_3_card import ROOT, _text, check_part + + +def test_table(): + check_part( + "table", + "ui_template_table.ts", + ["ui_template_table.css"], + ["createTable", "tableShell", "tableRow", "emptyRow"], + ["ui-table", "ui-table--data", "ui-table-wrap", "ui-table__menucell", "ui-table__empty"], + ) + css = _text("ui_template_table.css") + assert "height: 40px" in css and "height: 28px" in css + for state in (".is-active", ".is-changed", ".is-removed", "tr.is-sub"): + assert state in css, state + kit = (ROOT / "B01_Dashboard" / "B01_Dashboard_UI_PageKit.ts").read_text(encoding="utf-8") + assert "createTable as dataTable," in kit # 한 벌 · 부름 꼴 그대로 + pages = (ROOT / "B01_Dashboard" / "B01_Dashboard_UI_Style_Pages.css").read_text( + encoding="utf-8" + ) + assert ".b01-pg-table" not in pages diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py new file mode 100644 index 000000000..120e4582f --- /dev/null +++ b/resources/tester/test_553_4_de_parts.py @@ -0,0 +1,84 @@ +"""553-4 예정노선(D01~D04) · 안정성평가(E01~E07) 부품 옮김 지킴 시험 — 서버 · 화면 없이 소스만 본다. +옮긴 페이지는 날 `` · `` · 날 색 · 안 쓰는 변종 CSS 가 다시 생기면 안 됨.""" + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _src(path: str) -> str: + return (ROOT / path).read_text(encoding="utf-8") + + +def _no_hex(path: str) -> None: + css = re.sub(r"/\*.*?\*/", "", _src(path), flags=re.S) + assert not re.search(r"#[0-9a-fA-F]{3,8}\b", css), f"{path}: 날 색" + + +def test_d01_station_label_is_text_part(): + ts = _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts") + assert "style.cssText" not in ts + assert 'createText("", "muted")' in ts + assert 'dataset.station = ""' in ts # 시험 · 촬영이 잡음 + + +def test_d02_parts(): + ts = _src("D02_PlanBase/D02_PlanBase_UI_Page.ts") + css = _src("D02_PlanBase/D02_PlanBase_UI_Style.css") + assert 'el("input"' not in ts + assert 'createInputField({ type: "number"' in ts + assert 'classList.add("d02-info")' in ts # 촬영 장면이 `p.d02-info` 로 잡음 + for dead in (".d02-info", "is-invalid", "font-size: var(--text-caption);\n}\n\n.d02-info"): + assert dead not in css + assert "data-axis" in ts or "dataset.axis" in ts + + +def test_d03_parts(): + ts = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts") + three = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts") + css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css") + assert 'createElement("progress")' not in ts and "createProgressBar()" in ts + assert "createCard({" in ts and "onSelect:" in ts + assert 'classList.toggle("is-picked"' in ts # 촬영 장면이 `.d03-plan__mode.is-picked` 로 잡음 + assert "card.dataset.mode = mode" in ts + assert 'createElement("select")' not in three and "createSelectField" in three + assert 'createElement("input")' not in three and "createCheckbox" in three + for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"): + assert dead not in css, dead + + +def test_d04_parts(): + ts = _src("D04_PlanConfirm/D04_PlanConfirm_UI_Page.ts") + css = _src("D04_PlanConfirm/D04_PlanConfirm_UI_Style.css") + assert 'el("input"' not in ts + assert ts.count("createInputField(") == 2 and "createCheckbox(" in ts + assert 'createBanner({ tone: "warning"' in ts + assert "input:checked" in ts # [확정] 이 고른 연도를 읽는 길 + assert "border:" not in css.split(".d04-confirm__input")[1].split("}")[0] + assert ".d04-confirm__dirty" not in css + assert 'el("table"' not in ts and "tableShell(columns)" in ts + assert "d04-confirm__table" 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") + assert "createTable(" in tables and "createTHead" not in tables + assert 'className: "ui-field e02-field"' in tables # 칸 이름 줄은 입력 부품의 ui-field + assert ".e02-field" not in css and "border-collapse" not in css diff --git a/resources/tester/test_553_7_public_pages.py b/resources/tester/test_553_7_public_pages.py new file mode 100644 index 000000000..5a46c8aa8 --- /dev/null +++ b/resources/tester/test_553_7_public_pages.py @@ -0,0 +1,117 @@ +"""553-7 공개 화면(A01~A09) · 로그인 · B10 · B11 부품 옮김 지킴 시험 — 서버 · 화면 없이 소스로. + +· A01~A05 = 공개 화면 틀(createPublicFrame) 한 벌 — 페이지마다 hero · section 복제 CSS 없음 +· 카드 = createCard · 알림 = createBanner · 배지 = createBadge · 여러 줄 = createInputField(multiline) +· 페이지 CSS 에 날 색(#hex · rgb · 옛 보라 이름) 없음 · 죽은 A07_Register_UI_Page 없음 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] + +PUBLIC_PAGES = { + "A01_Home/A01_Home_UI_Page.ts": ("A01_Home/A01_Home_UI_Style.css", "a01"), + "A02_ProgDetail/A02_ProgDetail_UI_Page.ts": ( + "A02_ProgDetail/A02_ProgDetail_UI_Style.css", + "a02", + ), + "A03_CompDetail/A03_CompDetail_UI_Page.ts": ( + "A03_CompDetail/A03_CompDetail_UI_Style.css", + "a03", + ), + "A04_NewsHistory/A04_NewsHistory_UI_Page.ts": ( + "A04_NewsHistory/A04_NewsHistory_UI_Style.css", + "a04", + ), + "A05_EduDetail/A05_EduDetail_UI_Page.ts": ("A05_EduDetail/A05_EduDetail_UI_Style.css", "a05"), +} +CARD_PAGES = [ + "A06_Login/A06_Login_UI_Auth_Page.ts", + "A07_Register/A07_Register_UI_Auth_Page.ts", + "A08_Support/A08_Support_UI_Page.ts", + "A09_Security/A09_Security_UI_Page.ts", +] +CSS_FILES = [css for css, _ in PUBLIC_PAGES.values()] + [ + "A06_Login/A06_Login_UI_Style.css", + "A07_Register/A07_Register_UI_Style.css", + "A08_Support/A08_Support_UI_Style.css", + "A09_Security/A09_Security_UI_Style.css", + "B10_Payment/B10_Payment_UI_Style.css", + "B11_Status/B11_Status_UI_Style.css", +] +RAW_COLOR = re.compile( + r"#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|var\(--(?:b01|draw)-|" + r"var\(--color-(?:deep-iris|mist-violet|slate|charcoal|ash|plum-velvet)\)" +) + + +def _text(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def _no_comments(css: str) -> str: + return re.sub(r"/\*.*?\*/", "", css, flags=re.S) + + +@pytest.mark.parametrize("page", PUBLIC_PAGES) +def test_public_page_uses_frame(page: str) -> None: + src = _text(page) + assert "createPublicFrame" in src + for tag in ("hero", "__inner", "__subtitle", "__ctas"): + assert tag not in src, f"{page}: 손 hero 조각 {tag} 남음" + + +@pytest.mark.parametrize("page", PUBLIC_PAGES) +def test_public_css_has_no_hero_copy(page: str) -> None: + css = _no_comments(_text(PUBLIC_PAGES[page][0])) + prefix = PUBLIC_PAGES[page][1] + assert f".{prefix}-hero" not in css + assert f".{prefix}-section" not in css + + +@pytest.mark.parametrize("page", CARD_PAGES) +def test_card_pages_use_card_part(page: str) -> None: + assert "createCard" in _text(page) + + +@pytest.mark.parametrize("css", CSS_FILES) +def test_page_css_no_raw_color(css: str) -> None: + assert not RAW_COLOR.search(_no_comments(_text(css))) + + +def test_a07_agreement_uses_checkbox_and_collapsible() -> None: + src = _text("A07_Register/A07_Register_UI_Auth_Page.ts") + assert "createCheckbox" in src and "attachCollapsible" in src + assert " None: + src = _text("A08_Support/A08_Support_UI_Page.ts") + assert "createBanner" in src and "multiline" in src + assert "createTextAreaField" not in src and 'createElement("textarea")' not in src + + +def test_b10_b11_use_parts() -> None: + b10 = _text("B10_Payment/B10_Payment_UI_Page.ts") + b11 = _text("B11_Status/B11_Status_UI_Page.ts") + assert "createBanner" in b10 and "createBadge" in b10 and "createTag" not in b10 + assert "createCard" in b11 and "createBadge" in b11 and "createTag" not in b11 + assert ".b10-payment__note" not in _text("B10_Payment/B10_Payment_UI_Style.css") + assert ".b11-status__step {" not in _text("B11_Status/B11_Status_UI_Style.css") + + +def test_dead_register_page_gone() -> None: + assert not (ROOT / "A07_Register/A07_Register_UI_Page.ts").exists() + + +def test_public_frame_wide_option() -> None: + ts = _text("ui_template/ui_template_public_layout.ts") + css = _text("ui_template/ui_template_public_layout.css") + assert "wide?: boolean" in ts + assert "[data-wide] .ui-public-frame__title" in css diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index ca94329e9..8b8f4fd80 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -236,9 +236,9 @@ def test_저장된_표준단면이_브라우저까지_간다(): # 값으로 재는 확인은 화면 실측에 있음(보조 창 b269ea34 81.77m — 단 0 → 1 에서 성토 # 10.9027 → 10.6743㎡). 계획서에 그 수치가 남아 있다. def test_다단_성토부선이_면적_트림에_들어간다(): - # 다단 · 묻힌 구간 거르기는 `_Culvert_Tiers` 로 나뉘어 있어 두 파일을 함께 읽음. + # 다단 · 묻힌 구간 거르기는 `logic/_Cross_Culvert_Tiers` 로 나뉘어 있어 두 파일을 함께 읽음. source = "".join( - (PROJECT_ROOT / "B06_Section" / f"B06_Section_UI_Cross_Culvert_{name}.ts").read_text( + (PROJECT_ROOT / "B06_Section" / "logic" / f"B06_Section_Cross_Culvert_{name}.ts").read_text( encoding="utf-8" ) for name in ("Geom", "Tiers") diff --git a/ui_template/ui_template_banner.css b/ui_template/ui_template_banner.css index 6955ea3c9..b1cb1f764 100644 --- a/ui_template/ui_template_banner.css +++ b/ui_template/ui_template_banner.css @@ -14,6 +14,9 @@ font-size: 13px; line-height: 1.45; } +.ui-banner[hidden] { + display: none; +} .ui-banner--warning { --banner-tone: var(--color-warning); } @@ -51,19 +54,4 @@ } .ui-banner__close { flex: none; - width: 20px; - height: 20px; - padding: 0; - border: 0; - border-radius: var(--radius-sm); - background: none; - color: var(--color-text-muted); - font: inherit; - font-size: var(--text-caption); - line-height: 20px; - cursor: pointer; -} -.ui-banner__close:is(:hover, [data-state="hover"]) { - background: var(--color-hover); - color: var(--color-text); } diff --git a/ui_template/ui_template_banner.ts b/ui_template/ui_template_banner.ts index 58b2d10e9..7e78d5ecf 100644 --- a/ui_template/ui_template_banner.ts +++ b/ui_template/ui_template_banner.ts @@ -2,7 +2,7 @@ * ui_template_banner.ts * 부품 「띠 알림」(552-3 · 목록 25) — 화면 안에 머무는 알림 줄(토스트처럼 사라지지 않음). * 꼴 = 왼쪽 색 띠 + 옅은 바탕 · 갈래 넷(정보 · 경고 · 위험 · 성공 — 성공은 ✓ 같이) · 제목(굵게) · 펼침(`details` → 「자세히」) - * · 안 단추(`actions`) · 닫기(`onClose` → ✕) + * · 안 단추(`actions`) · 닫기(`onClose` → ✕ 아이콘 단추 20) * 크기 = 글 13 · 줄 최소 32 · 여백 8 / 12 * 상태 = 기본만 * 쓰는 법 = `createBanner({ tone: "warning", text: "저장 안 한 고침이 있음", actions: [createButton(…)] })` @@ -10,6 +10,7 @@ * · M01 `m01-master__notice` · `m01lab__warn` · M02 `m02-main__notice` · `m02-sod__banner` …(약 20 벌 · 목록 4장) * ========================================================================== */ +import { createIconButton } from "./ui_template_button"; import { el } from "./ui_template_elements_base"; import "./ui_template_banner.css"; @@ -50,11 +51,8 @@ export function createBanner(opts: BannerOptions): HTMLElement { root.append(el("div", { className: "ui-banner__actions", children: opts.actions })); if (opts.onClose) { const onClose = opts.onClose; - const close = el("button", { - className: "ui-banner__close", - text: "✕", - attrs: { type: "button", title: "닫기", "aria-label": "닫기" }, - }); + const close = createIconButton({ icon: "x", title: "닫기", size: 20 }); + close.classList.add("ui-banner__close"); close.addEventListener("click", () => { root.remove(); onClose(); diff --git a/ui_template/ui_template_context_menu.css b/ui_template/ui_template_context_menu.css index f688d3f96..14b30400e 100644 --- a/ui_template/ui_template_context_menu.css +++ b/ui_template/ui_template_context_menu.css @@ -62,3 +62,57 @@ margin: var(--spacing-4) 0; background: var(--color-border); } + +/* ⋯ 줄 끝 메뉴(552-3) — 대시보드 꼴(단추 28 · 판 132 · 항목 줄 32) */ +.ui-row-menu { + position: relative; + display: inline-block; +} +.ui-row-menu__btn { + display: grid; + --ui-size-icon: 18px; +} +.ui-row-menu.is-open .ui-row-menu__btn { + background: var(--color-hover); + color: var(--color-text); +} +.ui-row-menu__pop { + position: absolute; + right: 0; + top: 30px; + z-index: var(--z-dropdown); + min-width: 132px; + padding: var(--spacing-4); + background: var(--color-surface-raised); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-lg); + text-align: left; +} +.ui-row-menu__item { + display: block; + width: 100%; + min-height: 32px; + padding: 7px 10px; + border: 0; + border-radius: 6px; + background: none; + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; + white-space: nowrap; +} +.ui-row-menu__item:is(:hover, [data-state="hover"]):not(:disabled) { + background: var(--color-hover); +} +.ui-row-menu__item:is(:active, [data-state="active"]):not(:disabled) { + background: var(--color-surface-sunken); +} +.ui-row-menu__item:disabled { + color: var(--color-text-muted); + cursor: not-allowed; +} +.ui-row-menu__item.is-danger { + color: var(--color-danger); +} diff --git a/ui_template/ui_template_context_menu.ts b/ui_template/ui_template_context_menu.ts index 514413a6c..79d58cc86 100644 --- a/ui_template/ui_template_context_menu.ts +++ b/ui_template/ui_template_context_menu.ts @@ -11,7 +11,9 @@ * 모양은 공용 CSS 한 벌(`ui_template_context_menu.css` · `.ui-ctx-menu`) — 나무 메뉴와 같음(계획서 39-9b). * ========================================================================== */ +import { createIconButton } from "./ui_template_button"; import "./ui_template_context_menu.css"; +import { showConfirmDialog } from "./ui_template_modal"; /** 평면 항목 = [문구, 동작]. 하위 메뉴 항목 = {label, children} — 「글 ▸」 한 줄 · 올리거나 누르면 * 오른쪽 옆 새 판(하위 메뉴 · 화면 오른쪽 끝이면 왼쪽) · → 열기 · ← 닫기(PLAN 246-2). @@ -223,3 +225,74 @@ export function createMapContextMenu(blockClass: string): MapContextMenu { contains: (target) => target instanceof Node && element.contains(target), }; } + +/* ============================================================================= + * 부품 「⋯ · 오른쪽 메뉴」(552-3 · 목록 20) + * 꼴 = 줄 끝 ⋯(`createRowMenu` · 아이콘 단추 28 → 아래 판) · 오른쪽 누름 메뉴(`createMapContextMenu` · 위 · 하위판 ▸ · 구분선) + * · 위험 항목(`danger` → 빨강 + 확인창 · `ownConfirm` 이면 확인창 없이) + * 크기 = ⋯ 단추 28(아이콘 18) · 항목 줄 32 · 판 최소 폭 132(⋯) / 160(오른쪽 누름) + * 상태 = 기본 · 마우스(항목 바탕) · 눌림(.is-open · 단추 바탕) · 막힘(항목 disabled) + * 쓰는 법 = `createRowMenu([{ label: "열기", onClick: open }, { label: "삭제", danger: true, onClick: drop }])` + * 대신하는 옛 변종 = B01 `rowMenu`(`.b01-pg-menu`) · `.b01-proj-menu__context-menu` · 시트 `.ss-toolbar__popover` · B06 `b06-design__more` · CAD `.cad-ribbon-overflow` + * 한 번에 하나 · 밖 누름 · Esc 로 닫힘 = 위 장부(enterMenu · leaveMenu)를 같이 씀 + * ========================================================================== */ + +export interface RowMenuItem { + label: string; + onClick: () => void; + /** 삭제 · 빼기 = 빨강 + 확인창 */ + danger?: boolean; + /** 확인창 글(danger 일 때) */ + confirm?: string; + /** 누르면 열리는 모달이 확인을 맡음 — 빨강만 · 확인창 없음 */ + ownConfirm?: boolean; + disabled?: boolean; +} + +/** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · danger 는 확인 뒤 실행 */ +export function createRowMenu(items: RowMenuItem[]): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "ui-row-menu"; + if (!items.length) return wrap; + const trigger = createIconButton({ icon: "dots", title: "더 보기", size: 28 }); + trigger.classList.add("ui-row-menu__btn"); + trigger.setAttribute("aria-haspopup", "menu"); + trigger.setAttribute("aria-expanded", "false"); + const pop = document.createElement("div"); + pop.className = "ui-row-menu__pop"; + pop.setAttribute("role", "menu"); + pop.hidden = true; + const entry: MenuEntry = { + close: () => setOpen(false), + contains: (target) => target instanceof Node && wrap.contains(target), + }; + function setOpen(open: boolean): void { + pop.hidden = !open; + trigger.setAttribute("aria-expanded", String(open)); + wrap.classList.toggle("is-open", open); + if (open) enterMenu(entry); + else leaveMenu(entry); + } + for (const item of items) { + const b = document.createElement("button"); + b.type = "button"; + b.className = `ui-row-menu__item${item.danger ? " is-danger" : ""}`; + b.textContent = item.label; + b.disabled = !!item.disabled; + b.setAttribute("role", "menuitem"); + b.addEventListener("click", () => { + setOpen(false); + if (!item.danger || item.ownConfirm) return item.onClick(); + void showConfirmDialog(item.confirm ?? `${item.label} 하시겠습니까?`, item.label).then( + (ok) => ok && item.onClick(), + ); + }); + pop.append(b); + } + trigger.addEventListener("click", (e) => { + e.stopPropagation(); + setOpen(pop.hidden === true); + }); + wrap.append(trigger, pop); + return wrap; +} diff --git a/ui_template/ui_template_drop.css b/ui_template/ui_template_drop.css new file mode 100644 index 000000000..ce496ac9b --- /dev/null +++ b/ui_template/ui_template_drop.css @@ -0,0 +1,52 @@ +/* 파일 놓기 칸(552-3) — 점선 칸 · 끌어 오면 강조 · 오류 빨강 */ +.ui-drop { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-8); + min-height: 48px; + padding: var(--spacing-16); + border: 1px dashed var(--color-border-strong); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); + color: var(--color-text); + text-align: center; + transition: + border-color var(--transition-fast), + background-color var(--transition-fast); +} +.ui-drop__icon { + color: var(--color-text-muted); +} +.ui-drop__text, +.ui-drop__hint, +.ui-drop__error { + margin: 0; +} +.ui-drop__text { + font-size: var(--text-body-sm); +} +.ui-drop__hint { + font-size: var(--text-caption); + color: var(--color-text-muted); +} +.ui-drop .ui-progress { + align-self: stretch; +} +.ui-drop:is(.is-over, [data-state="hover"]) { + border-color: var(--color-accent); + background: var(--color-accent-soft); +} +.ui-drop:is(.is-over, [data-state="hover"]) .ui-drop__icon { + color: var(--color-accent); +} +.ui-drop[aria-disabled="true"] { + opacity: 0.5; +} +.ui-drop:is(.is-error, [data-state="error"]) { + border-color: var(--color-danger); +} +.ui-drop__error { + font-size: var(--text-caption); + color: var(--color-danger); +} diff --git a/ui_template/ui_template_drop.ts b/ui_template/ui_template_drop.ts new file mode 100644 index 000000000..46319100e --- /dev/null +++ b/ui_template/ui_template_drop.ts @@ -0,0 +1,104 @@ +/* ============================================================================= + * ui_template_drop.ts + * 부품 「파일 놓기 칸」(552-3 · 목록 34) — 파일을 끌어 놓거나 [파일 고르기]로 고르는 점선 칸. + * 꼴 = 점선 칸(올림 아이콘 · 한 줄 · 고르기 단추 · 받는 꼴 안내) · 진행(`setProgress` → 진행 막대) · 오류(`setError` → 빨간 글) + * 크기 = 칸 최소 48 · 단추 28 + * 상태 = 기본 · 마우스(파일을 끌어 옴 = .is-over) · 막힘(`disabled`) · 오류(.is-error) + * 쓰는 법 = `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, + onClick: () => input.click(), + }); + 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", + 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 + }); + 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.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, + }; +} diff --git a/ui_template/ui_template_elements.ts b/ui_template/ui_template_elements.ts index 467a2c2a4..4d9425a5c 100644 --- a/ui_template/ui_template_elements.ts +++ b/ui_template/ui_template_elements.ts @@ -10,7 +10,6 @@ * ========================================================================== */ import { el } from "./ui_template_elements_base"; -import { createButton } from "./ui_template_button"; // 차트·기본 스타일 조각은 파일이 700줄을 넘어 떼어냈다(2026-09-04). // 여기서 그대로 다시 내보내 호출부의 import 경로는 불변이다. @@ -69,43 +68,8 @@ export { * 확인을 받아 어느 실행 환경에서도 같은 동작을 보장한다. * -------------------------------------------------------------------------- */ -/** 확인/취소 모달 — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */ -export function showConfirmDialog( - message: string, - confirmLabel = "확인", - cancelLabel = "취소", -): Promise { - return new Promise((resolve) => { - const overlay = el("div", { className: "ui-confirm" }); - const panel = el("div", { className: "ui-confirm__panel" }); - const text = el("p", { className: "ui-confirm__message", text: message }); - const actions = el("div", { className: "ui-confirm__actions" }); - const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리 - const done = (result: boolean): void => { - overlay.remove(); - if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); - resolve(result); - }; - const cancel = createButton({ - label: cancelLabel, - variant: "ghost", - onClick: () => done(false), - }); - actions.append( - cancel, - createButton({ label: confirmLabel, variant: "danger", onClick: () => done(true) }), - ); - overlay.addEventListener("keydown", (ev) => { - if (ev.key !== "Escape") return; - ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음 - done(false); - }); - panel.append(text, actions); - overlay.append(panel); - document.body.append(overlay); - cancel.focus(); // 창에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음 - }); -} +// 부품 「모달 · 확인창」(552-3) = ui_template_modal.ts — 부름 꼴 그대로 +export { askConfirm, askText, showConfirmDialog } from "./ui_template_modal"; /* ----------------------------------------------------------------------------- * 7. 워크플로우 3단 레이아웃 셸 (frontend.md §2) diff --git a/ui_template/ui_template_modal.css b/ui_template/ui_template_modal.css index e104fc91e..ac1b4bc67 100644 --- a/ui_template/ui_template_modal.css +++ b/ui_template/ui_template_modal.css @@ -1,11 +1,11 @@ .ui-modal__backdrop { position: fixed; inset: 0; - z-index: 1000; + z-index: var(--z-modal); display: flex; align-items: flex-start; justify-content: center; - background-color: rgba(38, 17, 74, 0.35); /* 공용 확인 창(.ui-confirm)과 같이 뒤를 가림 */ + background-color: var(--color-scrim); /* 공용 확인 창(.ui-confirm)과 같이 뒤를 가림 */ } /* 위에서 고정 거리 — 최대 높이일 때 가운데 자리에 윗변을 붙이고 아래로만 늘고 줆(찾기 칸이 안 움직임). @@ -64,3 +64,25 @@ flex-direction: column; gap: var(--spacing-8); } + +/* 모달 · 확인창(552-3) — 폭 단 · 확인창 제목 · 글 묻기 칸 */ +.ui-modal[data-width="24rem"] { + width: min(24rem, 92vw); +} +.ui-modal[data-width="40rem"] { + width: min(40rem, 92vw); +} +.ui-modal[data-width="960px"] { + width: min(960px, 92vw); +} +.ui-confirm__title { + margin: 0 0 var(--spacing-8); + font-size: var(--text-body); + font-weight: 700; + color: var(--color-text); +} +.ui-confirm__input { + box-sizing: border-box; + width: 100%; + margin-bottom: var(--spacing-16); +} diff --git a/ui_template/ui_template_modal.ts b/ui_template/ui_template_modal.ts index 77fb4fa25..1ff244181 100644 --- a/ui_template/ui_template_modal.ts +++ b/ui_template/ui_template_modal.ts @@ -1,14 +1,20 @@ /* ============================================================================= * ui_template_modal.ts - * 공용 모달 겉틀 — 배경 · 제목줄 · 닫기 · Esc · 포커스. 몸은 `mount` 가 채움. - * 바깥 누르기 = 누름 · 뗌 둘 다 배경일 때만 닫음(칸 끌기로 안 닫힘). - * M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임. - * 곁값 — `actions`(머리 [닫기] 바로 왼쪽 단추 · mount 셋째 인자 자리에 넣어도 됨) · `wide`(큰 창 96vw × 94vh). - * 안 넘긴 창은 지금 꼴 그대로. + * 부품 「모달 · 확인창」(552-3 · 목록 22) — 화면을 가리고 위에 뜨는 창. + * 꼴 = 모달(`openModal` · 제목줄 · 닫기 · 몸은 `mount`) · 넓음(`wide` · 96vw × 94vh) · 폭 단(`width`: 24rem · 40rem · 960px · 기본 720) + * · 확인창(`askConfirm`) · 위험 확인(`danger` · 확인 단추 빨강 = `showConfirmDialog`) · 글 묻기(`askText` · window.prompt 대신) + * 크기 = 모달 여백 16 · 둥글기 12 · 확인창 폭 300 ~ 420 · 단추 = 단추 부품 기본 + * 상태 = 기본만 · Esc = 닫기(한글 조합 중 · 몸이 막은 Esc 는 안 닫음) · 바깥 누르기 = 누름 · 뗌 둘 다 배경일 때만 + * 쓰는 법 = `openModal({ title: "구조물", closeLabel: "닫기", mount: (body) => body.append(form) })` + * · `if (await askConfirm({ message: "지울까요?", danger: true })) …` · `const name = await askText({ message: "블록 이름" })` + * 대신하는 옛 변종 = 시트 `.ss-dialog` · `.ss-fmtdlg` · B03 `b03-file__modal` · M01 `m01-logic__pick` · `m01lab__modal` · CAD `window.confirm` · `window.prompt`(갈아 끼우기 = 553) + * M01 · M02 가 함께 씀 — `dialogClass` · `backdropClass` 로 화면별 겉모양을 덧붙임 · 안 넘긴 창은 지금 꼴 그대로. + * 확인창 모양 `.ui-confirm*` = `ui_template_elements_styles.ts`(주입 차례가 페이지 CSS 뒤라 그대로 둠) · 덧붙임 = 이 파일 CSS. * ========================================================================== */ import "./ui_template_modal.css"; -import { createButton, el } from "./ui_template_elements"; +import { createButton } from "./ui_template_button"; +import { el } from "./ui_template_elements_base"; export interface ModalOptions { title: string; @@ -20,32 +26,44 @@ export interface ModalOptions { actions?: HTMLElement[]; /** 큰 창 — 96vw × 94vh · 몸 넘침만 스크롤 */ wide?: boolean; + /** 폭 단 — 없으면 720 */ + width?: "24rem" | "40rem" | "960px"; backdropClass?: string; /** 닫기 단추 · Esc · 바깥 누르기 전에 물음 — false 면 안 닫음(몸의 `close` 는 묻지 않고 닫음) */ beforeClose?: () => boolean | Promise; } -export function openModal(opts: ModalOptions): { close: () => void } { +/** 모달 판 — 뒤 가림막 없이 판만(부품 모음 견본도 씀) · 머리 단추는 mount 뒤에 붙임 */ +export function buildModal( + opts: Pick, + onClose: () => void, +): { dialog: HTMLElement; body: HTMLElement; actions: HTMLElement; closeButton: HTMLElement } { const body = el("div", { className: "ui-modal__body" }); const actions = el("div", { className: "ui-modal__actions", children: opts.actions ?? [] }); const closeButton = createButton({ label: opts.closeLabel, variant: "ghost", - onClick: () => void ask(), + onClick: onClose, }); const top = el("div", { className: "ui-modal__top", children: [el("h3", { text: opts.title }), closeButton], }); - const close = (): void => backdrop.remove(); - const ask = async (): Promise => { - if (!opts.beforeClose || (await opts.beforeClose())) close(); - }; const dialog = el("div", { className: `ui-modal ${opts.wide ? "ui-modal--wide " : ""}${opts.dialogClass ?? ""}`.trim(), attrs: { role: "dialog", "aria-label": opts.title }, children: [top, body], }); + if (opts.width) dialog.dataset.width = opts.width; + return { dialog, body, actions, closeButton }; +} + +export function openModal(opts: ModalOptions): { close: () => void } { + const { dialog, body, actions, closeButton } = buildModal(opts, () => void ask()); + const close = (): void => backdrop.remove(); + const ask = async (): Promise => { + if (!opts.beforeClose || (await opts.beforeClose())) close(); + }; const backdrop = el("div", { className: `ui-modal__backdrop ${opts.backdropClass ?? ""}`.trim(), children: [dialog], @@ -69,3 +87,104 @@ export function openModal(opts: ModalOptions): { close: () => void } { (body.querySelector("input, textarea, select") ?? dialog).focus(); return { close }; } + +/* ----------------------------------------------------------------------------- + * 확인창 · 글 묻기 — window.confirm · window.prompt 대신 + * + * 공용 헤드 브라우저(playwright 연결)는 네이티브 confirm/alert를 자동 취소해 + * 버려 [초기화] 같은 버튼이 죽은 것처럼 보였다(2026-08-19 진단). 화면 안 창으로 + * 확인을 받아 어느 실행 환경에서도 같은 동작을 보장한다. + * -------------------------------------------------------------------------- */ + +export interface AskOptions { + message: string; + title?: string; + okLabel?: string; + cancelLabel?: string; + /** 위험 확인 — 확인 단추 빨강 */ + danger?: boolean; +} + +export interface AskField { + value?: string; + placeholder?: string; +} + +/** 확인창 판 — 가림막(.ui-confirm) 없이 판만(부품 모음 견본도 씀) · `field` = 글 묻기 칸 */ +export function confirmPanel( + opts: AskOptions & { field?: AskField }, + done: (ok: boolean, text: string) => void = () => {}, +): { panel: HTMLElement; first: HTMLElement } { + const panel = el("div", { className: "ui-confirm__panel", attrs: { role: "dialog" } }); + if (opts.title) panel.append(el("h3", { className: "ui-confirm__title", text: opts.title })); + panel.append(el("p", { className: "ui-confirm__message", text: opts.message })); + let input: HTMLInputElement | null = null; + if (opts.field) { + const box = el("input", { + className: "ui-input ui-confirm__input", + attrs: { type: "text", placeholder: opts.field.placeholder ?? "" }, + }); + box.value = opts.field.value ?? ""; + box.addEventListener("keydown", (ev) => { + if (ev.key !== "Enter" || ev.isComposing) return; + ev.preventDefault(); + done(true, box.value); + }); + panel.append(box); + input = box; + } + const cancel = createButton({ + label: opts.cancelLabel ?? "취소", + variant: "ghost", + onClick: () => done(false, ""), + }); + const ok = createButton({ + label: opts.okLabel ?? "확인", + variant: opts.danger ? "danger" : "filled", + onClick: () => done(true, input?.value ?? ""), + }); + panel.append(el("div", { className: "ui-confirm__actions", children: [cancel, ok] })); + return { panel, first: input ?? cancel }; +} + +function ask(opts: AskOptions, field?: AskField): Promise<{ ok: boolean; text: string }> { + return new Promise((resolve) => { + const overlay = el("div", { className: "ui-confirm" }); + const back = document.activeElement as HTMLElement | null; // 닫힐 때 초점을 돌려놓을 자리 + const done = (ok: boolean, text: string): void => { + overlay.remove(); + if (back?.isConnected && back !== document.body) back.focus({ preventScroll: true }); + resolve({ ok, text }); + }; + const { panel, first } = confirmPanel({ ...opts, field }, done); + overlay.addEventListener("keydown", (ev) => { + if (ev.key !== "Escape" || ev.isComposing) return; + ev.stopPropagation(); // 뒤 창 · 페이지 Esc 까지 닫지 않음 + done(false, ""); + }); + overlay.append(panel); + document.body.append(overlay); + first.focus(); // 창에 초점 — 뒤 칸에 글이 들어가지 않고 Esc 가 먹음 + }); +} + +/** 확인창 — 확인이면 true */ +export function askConfirm(opts: AskOptions): Promise { + return ask(opts).then((r) => r.ok); +} + +/** 글 묻기 — 확인이면 칸 글 · 취소면 null */ +export function askText(opts: AskOptions & AskField): Promise { + return ask(opts, { value: opts.value, placeholder: opts.placeholder }).then((r) => + r.ok ? r.text : null, + ); +} + +/** 위험 확인(옛 부름 꼴) — 확인이면 true. 네이티브 confirm 대신 반드시 이것을 쓴다. */ +export function showConfirmDialog( + message: string, + confirmLabel = "확인", + cancelLabel = "취소", +): Promise { + return askConfirm({ message, okLabel: confirmLabel, cancelLabel, danger: true }); +} diff --git a/ui_template/ui_template_public_layout.css b/ui_template/ui_template_public_layout.css index 7f537a983..98c35c3d4 100644 --- a/ui_template/ui_template_public_layout.css +++ b/ui_template/ui_template_public_layout.css @@ -8,7 +8,7 @@ .ui-public-frame__hero { padding: var(--spacing-96) var(--spacing-32); - border-bottom: 1px solid var(--color-border); + box-shadow: inset 0 -1px 0 var(--color-border); background: linear-gradient(to bottom, var(--color-accent-soft), var(--color-canvas)); } @@ -35,6 +35,14 @@ line-height: var(--leading-heading-lg); } +.ui-public-frame__hero[data-wide] .ui-public-frame__title { + max-width: 18ch; +} + +.ui-public-frame__hero[data-wide] .ui-public-frame__lead { + max-width: 56ch; +} + .ui-public-frame__lead { max-width: 52ch; margin: 0; @@ -66,6 +74,7 @@ color: var(--color-text); font-size: var(--text-heading-sm); line-height: var(--leading-heading-sm); + text-align: center; } .ui-public-frame__section-body { diff --git a/ui_template/ui_template_public_layout.ts b/ui_template/ui_template_public_layout.ts index 29b9120c8..edf92c797 100644 --- a/ui_template/ui_template_public_layout.ts +++ b/ui_template/ui_template_public_layout.ts @@ -3,7 +3,7 @@ * 공개 화면 틀 `public-frame`(552-4 틀) — 로그인 전 공개 페이지(A01 ~ A09) 몸통: 머리 띠(hero) · 구역 · 본문. * 꼴 = 머리 띠(제목 · 설명 · 주 단추 줄 · 가운데 / 왼쪽 맞춤) · 구역(제목 + 몸) 여럿 * 공개 머리(로고 · 메뉴)와 바닥글(`.app-footer`)은 앱 셸 몫 — 이 틀은 그 사이 본문만 - * 크기 = 폭 = --page-max-width · 제목 = --text-heading-lg(좁으면 --text-heading) · 구역 제목 = --text-heading-sm + * 크기 = 폭 = --page-max-width · 제목 = --text-heading-lg(좁으면 --text-heading · `wide` = 18 자) · 구역 제목 = --text-heading-sm(가운데) * 상태 = 없음 * 쓰는 법 = const f = createPublicFrame({ title: "임도 설계", lead: "…", actions: [btn] }); f.addSection("기능").append(grid) * 대신하는 옛 변종 = A01 ~ A05 `.a0N-hero` · `__inner` · `__title` · `__subtitle` · `__ctas` 다섯 벌 · `.a0N-section(__title)` @@ -17,6 +17,8 @@ export interface PublicFrameOptions { actions?: HTMLElement[]; /** 머리 띠 맞춤 — 기본 가운데 */ align?: "center" | "start"; + /** 제목 18 자 · 설명 56 자 폭(기본 14 · 52) */ + wide?: boolean; } export interface PublicFrame { @@ -41,6 +43,7 @@ export function createPublicFrame(opts: PublicFrameOptions): PublicFrame { const root = node("div", "ui-public-frame"); const hero = node("section", "ui-public-frame__hero"); hero.dataset.align = opts.align ?? "center"; + if (opts.wide) hero.dataset.wide = ""; const inner = node("div", "ui-public-frame__hero-inner"); inner.append(node("h1", "ui-public-frame__title", opts.title)); if (opts.lead) inner.append(node("p", "ui-public-frame__lead", opts.lead)); diff --git a/ui_template/ui_template_setting_row.css b/ui_template/ui_template_setting_row.css new file mode 100644 index 000000000..4bbf3f7f5 --- /dev/null +++ b/ui_template/ui_template_setting_row.css @@ -0,0 +1,42 @@ +/* 설정 줄(552-3) — 대시보드 꼴(줄 52 · 아래 선 · 카드 하나) */ +.ui-setting-card { + overflow: hidden; + background: var(--color-surface-raised); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); +} +.ui-setting-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--spacing-16); + min-height: 52px; + padding: var(--spacing-8) var(--spacing-16); + border-bottom: 1px solid var(--color-border); +} +.ui-setting-row:last-child { + border-bottom: 0; +} +.ui-setting-row__text { + display: flex; + flex-direction: column; + min-width: 0; +} +.ui-setting-row__desc { + font-size: var(--text-caption); + color: var(--color-text-muted); +} +.ui-setting-row__side { + display: flex; + align-items: center; + gap: var(--spacing-8); + flex: none; + max-width: 55%; +} +.ui-setting-row__side .ui-field__label:empty { + display: none; +} +.ui-setting-row__value { + text-align: right; + overflow-wrap: anywhere; +} diff --git a/ui_template/ui_template_setting_row.ts b/ui_template/ui_template_setting_row.ts new file mode 100644 index 000000000..0cfb25cd0 --- /dev/null +++ b/ui_template/ui_template_setting_row.ts @@ -0,0 +1,36 @@ +/* ============================================================================= + * ui_template_setting_row.ts + * 부품 「설정 줄」(552-3 · 목록 33) — 왼쪽 제목 · 설명 / 오른쪽 값 · 조작 한 줄(Linear 꼴) · 여러 줄은 둥근 상자 하나에. + * 꼴 = 값 줄(`valueText` · 없으면 「-」) · 조작 줄(오른쪽에 입력 · 고르기 · 켜기 · 단추) · 설명 없는 줄 + * 크기 = 줄 최소 52 · 여백 8 / 16 · 오른쪽 칸 최대 55 % + * 상태 = 기본만(조작 부품이 저마다 상태를 가짐) + * 쓰는 법 = `settingCard([settingRow("회사 이름", "보고서 머리에 들어감", valueText("산림설계"))])` + * 대신하는 옛 변종 = B01 `settingRow` · `settingCard`(`.b01-pg-row` · `.b01-pg-settings`) · `.b01-dashboard__setting-card` · E02 `e02-field` · E06 `e06-setting` · B08 `b08-hydro__row` + * ========================================================================== */ + +import { el } from "./ui_template_elements_base"; +import "./ui_template_setting_row.css"; + +/** 한 줄 — 제목 · 설명 왼쪽 · 값 · 조작 오른쪽 */ +export function settingRow(title: string, desc: string, right: Node): HTMLElement { + const left = el("div", { + className: "ui-setting-row__text", + children: [el("strong", { text: title })], + }); + if (desc) left.append(el("span", { className: "ui-setting-row__desc", text: desc })); + const side = el("div", { className: "ui-setting-row__side" }); + side.append(right); + return el("div", { className: "ui-setting-row", children: [left, side] }); +} + +/** 값 글 — 없으면 「-」 */ +export const valueText = (v: string | number | null | undefined): HTMLElement => + el("span", { + className: "ui-setting-row__value", + text: v === null || v === undefined || v === "" ? "-" : String(v), + }); + +/** 설정 줄 묶음 — 둥근 상자 한 개 */ +export function settingCard(rows: HTMLElement[]): HTMLElement { + return el("div", { className: "ui-setting-card", children: rows }); +} diff --git a/ui_template/ui_template_table.css b/ui_template/ui_template_table.css new file mode 100644 index 000000000..26914c069 --- /dev/null +++ b/ui_template/ui_template_table.css @@ -0,0 +1,83 @@ +/* 표(552-3) — 대시보드 꼴(줄 40 · 세로선 없음 · 카드 테두리) · 데이터표 = 줄 28 */ +.ui-table-wrap { + overflow-x: auto; + background: var(--color-surface-raised); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); +} +.ui-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + color: var(--color-text); +} +.ui-table th { + height: 36px; + padding: 0 12px; + text-align: left; + font-weight: 600; + font-size: 12px; + color: var(--color-text-muted); + border-bottom: 1px solid var(--color-border); + white-space: nowrap; +} +.ui-table td { + height: 40px; + padding: 0 12px; + border-bottom: 1px solid var(--color-border); + vertical-align: middle; +} +.ui-table tbody tr:last-child td { + border-bottom: 0; +} +.ui-table tbody tr:is(:hover, [data-state="hover"]) { + background: var(--color-hover); +} +.ui-table .is-end { + text-align: right; +} +.ui-table td.ui-table__menucell { + width: 44px; + padding: 0 8px; + text-align: right; +} + +/* 데이터표 — 줄 28 · 글 12 */ +.ui-table--data { + font-size: var(--text-caption); +} +.ui-table--data th { + height: 28px; + padding: 0 8px; +} +.ui-table--data td { + height: 28px; + padding: 0 8px; +} + +/* 줄 상태 — 고름 · 바뀜 · 지움 */ +.ui-table tbody tr:is(.is-active, [aria-selected="true"]) { + background: var(--color-accent-soft); +} +.ui-table tbody tr.is-changed > td:first-child { + box-shadow: inset 3px 0 0 var(--color-warning); +} +.ui-table tbody tr.is-removed { + color: var(--color-text-muted); + text-decoration: line-through; +} + +/* 빈 줄 · 펼침 줄 */ +.ui-table td.ui-table__empty { + height: 64px; + color: var(--color-text-muted); + text-align: center; +} +.ui-table tr.is-sub > td { + height: auto; + padding: 0; + background: var(--color-bg); +} +.ui-table tbody tr.is-sub:is(:hover, [data-state="hover"]) { + background: var(--color-bg); +} diff --git a/ui_template/ui_template_table.ts b/ui_template/ui_template_table.ts new file mode 100644 index 000000000..e7bd8ec9b --- /dev/null +++ b/ui_template/ui_template_table.ts @@ -0,0 +1,81 @@ +/* ============================================================================= + * ui_template_table.ts + * 부품 「표」(552-3 · 목록 18) — 머리 줄 + 줄들 · 세로선 없음 · 카드 테두리 · 넘치면 가로 스크롤. + * 꼴 = 일반표(줄 40 · 기본) · 데이터표(`density: "data"` · 줄 28 · 글 12) · ⋯ 칸(columns 의 label "" — 줄 끝 `createRowMenu`) + * · 한글 배지 칸(칸에 배지 요소) · 오른쪽 맞춤 칸(`align: "end"` · 수) · 빈 줄(`emptyRow`) · 펼침 줄(tr.is-sub) + * 크기 = 줄 40 / 28 · 머리 36 / 28 + * 상태 = 줄 마우스(옅은 바탕) · 고름(tr.is-active / aria-selected) · 바뀜(tr.is-changed) · 지움(tr.is-removed · 줄 그음) + * 쓰는 법 = `createTable([{ label: "이름" }, { label: "", width: "44px" }], [["임도 1", createRowMenu([…])]])` + * · 줄을 나중에 채우면 `const { root, tbody } = tableShell(columns); tbody.append(tableRow(columns, cells))` + * 대신하는 옛 변종 = B01 `dataTable` · `tableShell`(`.b01-pg-table`) · `.b01-proj-table` · `.ui-general-block__table` · D04 · E02 · E03 · E06 · E07 손 표 · M01 · M02 표(목록 4장) + * 옛 `.ui-data-table`(끌어 고치는 큰 표 · `ui_template_data_table.ts`)은 따로 그대로. + * ========================================================================== */ + +import { el } from "./ui_template_elements_base"; +import "./ui_template_table.css"; + +export type Cell = string | Node; + +export interface Column { + label: string; + /** 칸 너비(CSS) — 없으면 나눠 가짐 */ + width?: string; + align?: "end"; +} + +export interface TableOptions { + /** 데이터표(줄 28 · 글 12) */ + density?: "data"; +} + +/** 표 껍데기 — 머리 + 빈 tbody (줄은 호출 쪽이 채움 · 펼침 줄 같은 것을 끼우려고 떼어 둠) */ +export function tableShell( + columns: Column[], + opts: TableOptions = {}, +): { root: HTMLElement; tbody: HTMLElement } { + const table = el("table", { + className: `ui-table${opts.density === "data" ? " ui-table--data" : ""}`, + }); + const hr = el("tr"); + for (const col of columns) { + const th = el("th", { className: col.align ? "is-end" : "", text: col.label }); + if (col.width) th.style.width = col.width; + hr.append(th); + } + const tbody = el("tbody"); + table.append(el("thead", { children: [hr] }), tbody); + return { root: el("div", { className: "ui-table-wrap", children: [table] }), tbody }; +} + +/** 한 줄 — 칸 수는 columns 와 같게 · label "" 인 칸 = ⋯ 칸 */ +export function tableRow(columns: Column[], cells: Cell[]): HTMLTableRowElement { + const tr = el("tr"); + cells.forEach((cell, i) => { + const col = columns[i]; + const td = el("td", { + className: col?.align ? "is-end" : col?.label === "" ? "ui-table__menucell" : "", + }); + td.append(cell); + tr.append(td); + }); + return tr; +} + +/** 빈 줄 — 칸을 다 합쳐 한 줄 안내 */ +export function emptyRow(columns: Column[], text: string): HTMLTableRowElement { + const td = el("td", { className: "ui-table__empty", text }); + td.colSpan = columns.length; + return el("tr", { children: [td] }); +} + +/** 표 — 줄이 없으면 `empty` 글 한 줄 */ +export function createTable( + columns: Column[], + rows: Cell[][], + opts: TableOptions & { empty?: string } = {}, +): HTMLElement { + const { root, tbody } = tableShell(columns, opts); + tbody.append(...rows.map((cells) => tableRow(columns, cells))); + if (!rows.length && opts.empty) tbody.append(emptyRow(columns, opts.empty)); + return root; +} diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index 5bbadb761..47660f9dc 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -28,16 +28,13 @@ import { createSegment } from "./ui_template_segment"; import { createTabs } from "./ui_template_tabs"; import { createText } from "./ui_template_text"; import { createToggle } from "./ui_template_toggle"; -import { - badge, - dataTable, - rowMenu, - settingCard, - settingRow, - valueText, -} from "../B01_Dashboard/B01_Dashboard_UI_PageKit"; // 552-3 묶음 부품 import { createBanner } from "./ui_template_banner"; +import { createRowMenu, type RowMenuItem } from "./ui_template_context_menu"; +import { createDrop } from "./ui_template_drop"; +import { buildModal, confirmPanel } from "./ui_template_modal"; +import { settingCard, settingRow, valueText } from "./ui_template_setting_row"; +import { createTable, type Column } from "./ui_template_table"; import { createCard } from "./ui_template_card"; import { createPanelCard } from "./ui_template_collapsible"; import { createBarChart, createDonut, createLineChart } from "./ui_template_elements_chart"; @@ -110,6 +107,31 @@ const row = (...children: HTMLElement[]): HTMLElement => { const noop = (): void => {}; +/** 552-3 견본 — 표 칸 · 줄 · ⋯ 메뉴 항목 · 펼친 ⋯ 판 */ +const KIT_COLS: Column[] = [{ label: "노선" }, { label: "", width: "44px" }]; +const KIT_DATA: Column[] = [{ label: "노선" }, { label: "연장", align: "end" }]; +const KIT_MENU: RowMenuItem[] = [ + { label: "이름 바꾸기", onClick: noop }, + { label: "삭제", danger: true, onClick: noop }, +]; +const kitRows = (): (string | HTMLElement)[][] => [ + ["1", createRowMenu(KIT_MENU)], + ["2", createRowMenu(KIT_MENU)], +]; +const dataRows = [ + ["1", "1,080"], + ["2", "640"], +]; +const openMenu = (s: KitState, items = KIT_MENU): HTMLElement => { + const menu = createRowMenu(items); + menu.classList.add("is-open"); + const pop = menu.querySelector(".ui-row-menu__pop")!; + pop.hidden = false; + pop.style.position = "static"; + markState(pop.querySelector(".ui-row-menu__item")!, s); + return menu; +}; + /** 세로로 쌓기(552-3 견본) */ const col = (...children: HTMLElement[]): HTMLElement => { const box = row(...children); @@ -198,7 +220,7 @@ export const KIT_PARTS: KitPart[] = [ demo: (s) => (s === "error" ? null : row(...([["저장", "filled"], ["취소", "ghost"], ["보기", "pill"], ["삭제", "danger"], ["초기화", "danger-ghost"], ["사용법", "link"]] as const).map(([label, variant]) => markState(createButton({ label, variant, size: 28, onClick: noop }), s)))), sizes: (z) => createButton({ label: String(z), size: z, iconStart: "plus", onClick: noop }) }, { key: "icon-button", name: "아이콘 단추", group: "basic", kinds: "고스트 · 선 · 채움 · 켜짐(aria-pressed) · 글자 기호 · 여덟 단 · title 필수", file: "ui_template_button.ts", - demo: (s) => (s === "error" ? null : row(markState(createIconButton({ icon: "settings", title: "설정" }), s), markState(createIconButton({ icon: "refresh", title: "새로 고침", variant: "outline" }), s), markState(createIconButton({ icon: "plus", title: "더하기", variant: "filled" }), s), markState(createIconButton({ icon: "eye", title: "보기", pressed: true }), s), markState(createIconButton({ glyph: "▲", title: "위로", variant: "outline" }), s))), + demo: (s) => (s === "error" ? null : row(markState(createIconButton({ icon: "settings", title: "설정" }), s), markState(createIconButton({ icon: "refresh", title: "새로 고침", variant: "outline" }), s), markState(createIconButton({ icon: "plus", title: "추가", variant: "filled" }), s), markState(createIconButton({ icon: "eye", title: "보기", pressed: true }), s), markState(createIconButton({ glyph: "▲", title: "위로", variant: "outline" }), s))), sizes: (z) => createIconButton({ icon: "settings", title: `설정 ${z}`, size: z, variant: "outline" }) }, { key: "input", name: "입력", group: "basic", kinds: "글 · 수 · 단위 붙음 · 찾기 · 날짜 · 여러 줄 · 여덟 단", file: "ui_template_input.ts", demo: (s) => fieldDemo(s, () => { const f = createInputField({ placeholder: "폭", type: "number", unit: "m", size: 32 }); return { root: f.root, node: f.input, setError: f.setError }; }), @@ -230,13 +252,19 @@ export const KIT_PARTS: KitPart[] = [ { key: "collapsible", name: "접이 카드", group: "compound", kinds: "패널 카드 · 구역 · 캐럿 ▾ ▸ · ⚙ 곁값", file: "ui_template_collapsible.ts", demo: (s) => { if (s !== "default" && s !== "hover") return null; const open = createPanelCard("구조물 배치", false, noop); open.append(row(createInputField({ placeholder: "측점" }).root)); if (s === "hover") { markState(open.querySelector(".ui-collapsible__title")!, s); return open; } return col(open, createPanelCard("접힌 카드")); } }, - { key: "table", name: "표", group: "compound", kinds: "데이터표(28 · 밀도) · 일반표(40) · ⋯ 칸 · 한글 배지 · 빈 줄", file: "ui_template_table.ts", legacy: true, - demo: (s) => (s === "default" ? dataTable([{ label: "이름" }, { label: "상태", width: "90px" }, { label: "연장", width: "80px", align: "end" }, { label: "", width: "44px" }], [["임도 1 노선", badge("진행 중", "accent"), "1,080 m", rowMenu([{ label: "열기", onClick: noop }])], ["임도 2 노선", badge("완료", "success"), "640 m", rowMenu([{ label: "열기", onClick: noop }])]]) : null) }, - { key: "row-menu", name: "⋯ · 오른쪽 메뉴", group: "compound", kinds: "줄 끝 ⋯ · 오른쪽 누름 메뉴 · 위험 항목", file: "ui_template_context_menu.ts", legacy: true, - demo: (s) => (s === "default" ? rowMenu([{ label: "이름 바꾸기", onClick: noop }, { label: "삭제", danger: true, onClick: noop }]) : null) }, + { key: "table", name: "표", group: "compound", kinds: "일반표(줄 40) · 데이터표(줄 28) · ⋯ 칸 · 한글 배지 · 빈 줄 · 줄 고름 · 바뀜 · 지움", file: "ui_template_table.ts", + demo: (s) => { if (s === "disabled" || s === "error") return null; const t = s === "default" ? createTable(KIT_COLS, kitRows()) : createTable(KIT_DATA, dataRows, { density: "data" }); + const first = t.querySelector("tbody tr")!; if (s === "hover") markState(first, s); if (s === "active") first.classList.add("is-active"); + if (s !== "default") return t; const data = createTable(KIT_DATA, dataRows, { density: "data" }); const rows = data.querySelectorAll("tbody tr"); rows[0].classList.add("is-changed"); rows[1].classList.add("is-removed"); + return col(t, data, createTable(KIT_DATA, [], { empty: "노선 없음" })); } }, + { key: "row-menu", name: "⋯ · 오른쪽 메뉴", group: "compound", kinds: "줄 끝 ⋯(28) · 펼친 판 · 위험 항목(빨강 + 확인창) · 막힌 항목", file: "ui_template_context_menu.ts", + demo: (s) => { if (s === "error") return null; if (s === "default") return createRowMenu(KIT_MENU); if (s === "hover") { const m = createRowMenu(KIT_MENU); markState(m.querySelector(".ui-row-menu__btn")!, s); return m; } + return openMenu(s, s === "disabled" ? [{ label: "내보내기", onClick: noop, disabled: true }, ...KIT_MENU] : KIT_MENU); } }, { key: "empty", name: "빈 상태", group: "compound", kinds: "선 아이콘 · 한 줄 · 주 단추 · 사용법 · 작은 꼴", file: "ui_template_empty.ts", demo: (s) => (s === "default" ? col(createEmpty({ icon: "folder", text: "아직 프로젝트가 없음", hint: "새 프로젝트를 만들어 시작", action: createButton({ label: "새 프로젝트", onClick: noop }), onGuide: noop }), createEmpty({ text: "고른 측점 없음", small: true })) : null) }, - { key: "modal", name: "모달 · 확인창", group: "compound", kinds: "보통 · 넓음 · 확인 · 위험 확인", file: "ui_template_modal.ts" }, + { key: "modal", name: "모달 · 확인창", group: "compound", kinds: "보통(720) · 넓음 · 폭 단 24rem · 40rem · 960 · 확인 · 위험 확인 · 글 묻기(prompt 대신)", file: "ui_template_modal.ts", + demo: (s) => { if (s !== "default") return null; const m = buildModal({ title: "구조물 고르기", closeLabel: "닫기", width: "40rem" }, noop); m.body.append("몸 — 표 · 양식이 들어감"); m.dialog.style.margin = "0"; + return col(m.dialog, confirmPanel({ message: "노선을 저장할까요?" }).panel, confirmPanel({ title: "삭제", message: "임도 2 노선을 지울까요? 되돌릴 수 없음", okLabel: "삭제", danger: true }).panel, confirmPanel({ message: "블록 이름", field: { placeholder: "예: 배수관" } }).panel); } }, { key: "toast", name: "알림", group: "compound", kinds: "토스트 4꼴(성공 · 오류 · 정보 · 경고) · 로딩 가림막 · 진행 가림막", file: "ui_template_toast.ts", demo: (s) => (s === "default" ? col(toastNode("저장됨", "success"), toastNode("저장 못 함 — 다시 시도", "error"), toastNode("노선을 불러옴", "info"), toastNode("측점 간격이 넓음", "warning")) : null) }, { key: "banner", name: "띠 알림", group: "compound", kinds: "왼쪽 띠 · 정보 · 경고 · 위험 · 성공(✓) · 펼침 · 안 단추 · 닫기", file: "ui_template_banner.ts", @@ -252,9 +280,12 @@ export const KIT_PARTS: KitPart[] = [ demo: (s) => (s === "default" ? col(createBarChart([{ label: "산림설계", value: 12 }, { label: "녹색기술", value: 7 }, { label: "그 밖 3 곳", value: 4 }]), createDonut([{ label: "임도", value: 5 }, { label: "예정노선", value: 3 }, { label: "안정성", value: 2 }]), createLineChart({ series: [{ name: "CPU", values: [20, 35, 30, 50, 42, 60], colorIndex: 0 }], height: 120 })) : null) }, { key: "legend", name: "범례", group: "compound", kinds: "색 칩 · 선 · 점선 · 점 · 꺼짐 · 세로", file: "ui_template_flow_legend.ts", demo: (s) => (s === "default" ? col(createLegend([{ label: "임도", color: "var(--color-chart-0)" }, { label: "설계선", color: "var(--color-chart-2)", kind: "line" }, { label: "원지반선", color: "var(--color-chart-1)", kind: "dash" }, { label: "관", color: "var(--color-chart-3)", kind: "dot" }, { label: "꺼진 층", color: "var(--color-chart-0)", off: true }], { onToggle: noop }), createLegend([{ label: "절토", color: "var(--color-danger)" }, { label: "성토", color: "var(--color-info)" }], { vertical: true })) : null) }, - { key: "setting-row", name: "설정 줄", group: "compound", kinds: "제목 · 설명 · 오른쪽 값 · 조작", file: "ui_template_setting_row.ts", legacy: true, - demo: (s) => (s === "default" ? settingCard([settingRow("회사 이름", "보고서 머리에 들어감", valueText("산림설계")), settingRow("단위", "", valueText(null))]) : null) }, - { key: "drop", name: "파일 놓기 칸", group: "compound", kinds: "끌어 놓기 · 고르기 단추 · 진행", file: "ui_template_drop.ts" }, + { key: "setting-row", name: "설정 줄", group: "compound", kinds: "제목 · 설명 왼쪽 · 오른쪽 값 · 조작 · 줄 52", file: "ui_template_setting_row.ts", + demo: (s) => (s === "default" ? settingCard([settingRow("회사 이름", "보고서 머리에 들어감", valueText("산림설계")), settingRow("단위", "", valueText(null)), settingRow("기본 노폭", "새 노선에 먼저 들어감", createInputField({ placeholder: "3.0 m" }).root), settingRow("글꼴 파일", "", createButton({ label: "바꾸기", variant: "ghost", size: 28, onClick: noop }))]) : null) }, + { key: "drop", name: "파일 놓기 칸", group: "compound", kinds: "끌어 놓기 · 고르기 단추 · 받는 꼴 안내 · 진행 · 오류", file: "ui_template_drop.ts", + demo: (s) => { if (s === "active") return null; const d = createDrop({ accept: ".las,.laz", hint: "LAS · LAZ · 2 GB 까지", onFiles: noop, disabled: s === "disabled" }); + if (s === "error") d.setError("LAS · LAZ 파일만 받음"); if (s === "hover" || s === "error") markState(d.root, s); + if (s !== "default") return d.root; const busy = createDrop({ onFiles: noop }); busy.setProgress(0.4, "40 %"); return col(d.root, busy.root); } }, // ── 552-4 틀 ─────────────────────────────────────────────── { key: "work-frame", name: "작업 화면 틀", group: "frame", kinds: "왼쪽 패널(흰 판 · 1 px 선) · 본문 · 아래 단추 줄 · [?][<][>] · 머리 줄 없음 · ☰ 서랍", file: "ui_template_overlay.ts", demo: FRAME_DEMOS["work-frame"] },