Merge remote-tracking branch 'origin/dev' into sub_laptop_1

This commit is contained in:
2026-10-26 22:09:53 +09:00
76 changed files with 1619 additions and 1772 deletions
+46
View File
@@ -0,0 +1,46 @@
/* =============================================================================
* b_page_tabs.ts
* 페이지 탭 줄 — 부품 「탭」(`ui_template_tabs`)을 이미 있는 줄 요소 안에 채움(553-3 · B08 · B09 · ☰ [설정]).
*
* 쓰기 = `fillTabs(줄, { tabs, value, onChange, kind: "pill", data: "b08Tab" })` — 칸마다 `data-<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<HTMLElement>("[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;
}
+7 -5
View File
@@ -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);
+7 -46
View File
@@ -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 {
+47 -29
View File
@@ -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<HTMLElement>(".ui-input, .ui-select")) setUiSize(f, 28);
function refresh(): void {
undoBtn.disabled = !ctx.history.canUndo();
+30 -70
View File
@@ -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);
}
+7 -75
View File
@@ -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);
}
}
+25 -64
View File
@@ -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);
}
+7 -73
View File
@@ -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);
}
}
+16 -59
View File
@@ -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);
}
+3 -64
View File
@@ -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);
}
}
+18 -46
View File
@@ -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<HistoryKind, { variant: TagVariant; labelKey: keyof typeof ui_locales }> = {
const KIND_META: Record<HistoryKind, { variant: BadgeTone; labelKey: keyof typeof ui_locales }> = {
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<HistoryKind, { variant: TagVariant; labelKey: keyof type
/* -----------------------------------------------------------------------------
* 섹션 빌더
* -------------------------------------------------------------------------- */
function buildHero(): HTMLElement {
const hero = document.createElement("section");
hero.className = "a04-hero";
const inner = document.createElement("div");
inner.className = "a04-hero__inner";
const title = document.createElement("h1");
title.className = "a04-hero__title";
title.textContent = L("A04_NewsHistory_Title");
const subtitle = document.createElement("p");
subtitle.className = "a04-hero__subtitle";
subtitle.textContent = L("A04_NewsHistory_Hero_Subtitle");
inner.append(title, subtitle);
hero.append(inner);
return hero;
}
function buildTimeline(): HTMLElement {
const section = document.createElement("section");
section.className = "a04-section";
if (MOCK_HISTORY.length === 0) return createEmpty({ text: L("A04_NewsHistory_Empty") });
const list = document.createElement("div");
list.className = "a04-timeline";
if (MOCK_HISTORY.length === 0) {
const empty = document.createElement("p");
empty.className = "a04-timeline__empty";
empty.textContent = L("A04_NewsHistory_Empty");
section.append(empty);
return section;
}
for (const item of MOCK_HISTORY) {
const meta = KIND_META[item.kind];
const row = document.createElement("div");
row.className = "a04-timeline-row";
const tag = createTag(L(meta.labelKey), meta.variant);
const tag = createBadge(L(meta.labelKey), meta.variant);
const titleEl = document.createElement("span");
titleEl.className = "a04-timeline-row__title";
@@ -97,20 +67,22 @@ function buildTimeline(): HTMLElement {
dateEl.className = "a04-timeline-row__date";
dateEl.textContent = item.date;
row.append(tag, titleEl, dateEl);
const row = createCard({ body: [tag, titleEl, dateEl] });
row.classList.add("a04-timeline-row");
list.append(row);
}
section.append(list);
return section;
return list;
}
/* -----------------------------------------------------------------------------
* 페이지 진입점
* -------------------------------------------------------------------------- */
export function renderA04NewsHistory(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "a04-news-history";
page.append(buildHero(), buildTimeline());
root.append(page);
const frame = createPublicFrame({
title: L("A04_NewsHistory_Title"),
lead: L("A04_NewsHistory_Hero_Subtitle"),
wide: true,
});
frame.addSection().append(buildTimeline());
root.append(frame.root);
}
+7 -65
View File
@@ -1,54 +1,8 @@
/* =============================================================================
* A04_NewsHistory_UI_Style.css
* 최신소식/개선이력 페이지 고유 스타일 — theme.css 변수만 참조 (하드코딩 금지).
* 최신소식 · 개선 이력 고유 배치 — 머리 · 구역 · 카드 겉은 공용 부품(공개 화면 틀 · 카드)이 냄.
* ========================================================================== */
.a04-news-history {
display: flex;
flex-direction: column;
gap: var(--section-gap);
padding-bottom: var(--spacing-64);
}
/* --- Hero --- */
.a04-hero {
background: linear-gradient(to bottom, var(--color-mist-violet), var(--color-canvas));
padding: var(--spacing-96) var(--spacing-32);
}
.a04-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);
}
.a04-hero__title {
font-size: var(--text-heading-lg);
line-height: var(--leading-heading-lg);
color: var(--color-deep-iris);
max-width: 18ch;
}
.a04-hero__subtitle {
font-size: var(--text-body);
line-height: var(--leading-body);
color: var(--color-slate);
max-width: 56ch;
margin: 0;
}
/* --- 공통 섹션 --- */
.a04-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);
}
/* --- Timeline --- */
.a04-timeline {
display: flex;
@@ -56,31 +10,19 @@
gap: var(--spacing-8);
}
.a04-timeline-row {
display: flex;
padding: var(--spacing-16) var(--spacing-24);
}
.a04-timeline-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);
}
.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);
}
}
+21 -53
View File
@@ -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);
}
+1 -62
View File
@@ -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);
}
}
+3 -3
View File
@@ -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);
+2 -22
View File
@@ -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%;
}
+17 -24
View File
@@ -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<string> {
@@ -69,8 +63,6 @@ async function loadAgreementText(filename: string): Promise<string> {
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);
});
-141
View File
@@ -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);
}
+5 -46
View File
@@ -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);
}
+13 -57
View File
@@ -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);
+3 -25
View File
@@ -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);
+5 -4
View File
@@ -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);
}
+5 -4
View File
@@ -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 {
+11 -125
View File
@@ -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;
}
+2 -1
View File
@@ -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 {
+1 -142
View File
@@ -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 {
+5 -5
View File
@@ -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(
-8
View File
@@ -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);
}
+10 -5
View File
@@ -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;
+1 -11
View File
@@ -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 {
+2 -2
View File
@@ -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", () => {
+16 -12
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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"));
}
}
-12
View File
@@ -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);
}
+13 -9
View File
@@ -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";
@@ -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("평면에서 측점을 누르면 그 자리 지반선 · 예상 횡단이 보입니다.");
+35 -30
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
/** 뒤에서 도는 일을 끝날 때까지 지켜봄 — 진행은 지형 칸에. */
async function watch(kind: "terrain" | "analyze"): Promise<PlanJob | null> {
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<void> {
return null;
} finally {
busy = null;
bar.hidden = true;
bar.root.hidden = true;
refresh();
}
}
+2 -36
View File
@@ -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;
}
+45 -44
View File
@@ -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<void> {
const listId = `d04-companies-${year}`;
const options = el("datalist", { attrs: { id: listId } });
const found = new Map<string, number>();
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<void> {
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<typeof t>[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<typeof t>[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;
}),
);
}
@@ -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 {
@@ -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);
}
@@ -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;
}
+20 -14
View File
@@ -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<GroundResponse
export function renderE02StabGround(root: HTMLElement): void {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
const main = el("div", { className: "e02-page" });
const status = el("p", { className: "e02-status" });
const status = el("div", { className: "e02-status" });
const saveButton = createButton({
label: t("Common_Btn_Save"),
variant: "filled",
@@ -89,10 +90,9 @@ export function renderE02StabGround(root: HTMLElement): void {
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(resetButton, saveButton);
const panel = el("div", { className: "ui-sidebar-panel e02-panel" });
panel.append(el("p", { className: "e02-hint", text: t("E02_Note") }), status, actions);
panel.append(hook(createText(t("E02_Note")), "e02-hint"), status, actions);
mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel });
if (!projectId)
return void main.append(el("p", { className: "e02-empty", text: t("E02_NoProject") }));
if (!projectId) return void main.append(hook(createText(t("E02_NoProject")), "e02-empty"));
let view: E02View | null = null;
let refreshStations = (): void => {};
@@ -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<HTMLElement>(".is-invalid")) {
node.classList.remove("is-invalid");
for (const node of main.querySelectorAll<HTMLElement>("[data-error]")) {
node.classList.remove("is-error");
node.removeAttribute("aria-invalid");
delete node.dataset.error;
}
for (const error of errors) {
const node = main.querySelector<HTMLElement>(`[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",
),
);
}
})();
+8 -71
View File
@@ -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 {
+47 -41
View File
@@ -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 = <T extends HTMLElement>(node: T, name: string): T => (
node.classList.add(name),
node
);
export const clone = <T>(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), {
+7 -5
View File
@@ -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,
);
@@ -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}`);
},
@@ -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) : {};
@@ -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) : {};
@@ -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);
},
+4 -2
View File
@@ -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
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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)
+20
View File
@@ -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
+32
View File
@@ -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 # 한 벌
+25
View File
@@ -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
@@ -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
+23
View File
@@ -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
+84
View File
@@ -0,0 +1,84 @@
"""553-4 예정노선(D01~D04) · 안정성평가(E01~E07) 부품 옮김 지킴 시험 — 서버 · 화면 없이 소스만 본다.
옮긴 페이지는 날 `<input>` · `<progress>` · 날 색 · 안 쓰는 변종 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
+117
View File
@@ -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 "<details" not in src and 'createElement("details")' not in src
assert 'type = "checkbox"' not in src
def test_a08_uses_banner_and_multiline_input() -> 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
+2 -2
View File
@@ -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")
+3 -15
View File
@@ -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);
}
+4 -6
View File
@@ -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();
+54
View File
@@ -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);
}
+73
View File
@@ -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;
}
+52
View File
@@ -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);
}
+104
View File
@@ -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,
};
}
+2 -38
View File
@@ -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<boolean> {
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)
+24 -2
View File
@@ -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);
}
+131 -12
View File
@@ -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<boolean>;
}
export function openModal(opts: ModalOptions): { close: () => void } {
/** 모달 판 — 뒤 가림막 없이 판만(부품 모음 견본도 씀) · 머리 단추는 mount 뒤에 붙임 */
export function buildModal(
opts: Pick<ModalOptions, "title" | "closeLabel" | "actions" | "wide" | "width" | "dialogClass">,
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<void> => {
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<void> => {
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<HTMLElement>("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<boolean> {
return ask(opts).then((r) => r.ok);
}
/** 글 묻기 — 확인이면 칸 글 · 취소면 null */
export function askText(opts: AskOptions & AskField): Promise<string | null> {
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<boolean> {
return askConfirm({ message, okLabel: confirmLabel, cancelLabel, danger: true });
}
+10 -1
View File
@@ -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 {
+4 -1
View File
@@ -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));
+42
View File
@@ -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;
}
+36
View File
@@ -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 });
}
+83
View File
@@ -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);
}
+81
View File
@@ -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;
}
+48 -17
View File
@@ -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<HTMLElement>(".ui-row-menu__pop")!;
pop.hidden = false;
pop.style.position = "static";
markState(pop.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>("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<HTMLElement>(".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"] },