Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-26 21:34:56 +09:00
9 changed files with 303 additions and 14 deletions
+56
View File
@@ -0,0 +1,56 @@
/* 떠 있는 판(552-4) — 흰 판 · 1 px 선 · 그림자 · 머리 32 · 뒷막 없음 · 자리는 부르는 쪽(부모 안 absolute) */
.ui-float-panel {
position: absolute;
z-index: var(--z-dropdown);
display: flex;
flex-direction: column;
min-width: 160px;
max-height: 100%;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface-raised);
color: var(--color-text);
box-shadow: var(--shadow-lg);
}
.ui-float-panel__head {
display: flex;
flex: none;
align-items: center;
gap: var(--spacing-8);
min-height: 32px;
padding: 0 var(--spacing-4) 0 var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.ui-float-panel__head.is-draggable {
cursor: move;
touch-action: none;
user-select: none;
}
.ui-float-panel__title {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
font-size: var(--text-body-sm);
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-float-panel__body {
flex: 1 1 auto;
min-height: 0;
padding: var(--spacing-12);
overflow: auto;
font-size: var(--text-body-sm);
}
.ui-float-panel--popover > .ui-float-panel__body {
padding: var(--spacing-8);
}
.ui-float-panel.is-dragging {
opacity: 0.92;
}
+79
View File
@@ -0,0 +1,79 @@
/* =============================================================================
* ui_template_float_panel.ts
* 떠 있는 판 `float-panel`(552-4 틀) — 지도 · 그림 위에 뜨는 작은 판: 머리(제목 · 닫기 ✕) + 몸 · 뒷막 없음.
* 꼴 = 판(머리 + 몸) · 끌기 머리(draggable · 부모 안에서 옮김) · 팝오버(popover · 머리 없음 · 몸만)
* 크기 = 머리 data-size 32 · 닫기 = 아이콘 단추 24 · 폭 = width(기본 내용 폭)
* 상태 = 열림 · 닫힘(hidden) · 끄는 중(`is-dragging`)
* 쓰는 법 = const fp = createFloatPanel({ title: "측정", onClose: () => fp.root.remove() }); fp.body.append(table); map.append(fp.root)
* 대신하는 옛 변종 = B05 `b05-routeedit__hint/__info/__measure` · `b05-crossmodal` · `masshaul-overlay` · B06 `b06-structure-panel` ·
* B08 `b08-milestone__tools` · B09 `.b09-popup` · CAD `.cad-quick-properties` · E03 `e03-view3d` · M02 `m02-pop` · D03 `d03-plan__cross` …
* ========================================================================== */
import { createIconButton } from "./ui_template_button";
import "./ui_template_float_panel.css";
export interface FloatPanelOptions {
title?: string;
/** 있으면 머리에 닫기 ✕ */
onClose?: () => void;
/** 머리를 잡아 부모 안에서 옮김 */
draggable?: boolean;
/** 머리 없는 팝오버 */
popover?: boolean;
width?: number;
}
export interface FloatPanel {
root: HTMLElement;
body: HTMLElement;
}
/** 머리 끌기 — 부모(위치 잡힌 칸) 안에서 left · top 만 바꿈 */
function dragBy(root: HTMLElement, head: HTMLElement): void {
head.addEventListener("pointerdown", (down) => {
if ((down.target as HTMLElement).closest("button")) return;
const x0 = down.clientX - root.offsetLeft;
const y0 = down.clientY - root.offsetTop;
head.setPointerCapture(down.pointerId);
root.classList.add("is-dragging");
const move = (ev: PointerEvent): void => {
root.style.left = `${Math.max(0, ev.clientX - x0)}px`;
root.style.top = `${Math.max(0, ev.clientY - y0)}px`;
};
const up = (): void => {
root.classList.remove("is-dragging");
head.removeEventListener("pointermove", move);
};
head.addEventListener("pointermove", move);
head.addEventListener("pointerup", up, { once: true });
});
}
export function createFloatPanel(opts: FloatPanelOptions): FloatPanel {
const root = document.createElement("section");
root.className = `ui-float-panel${opts.popover ? " ui-float-panel--popover" : ""}`;
if (opts.width) root.style.width = `${opts.width}px`;
if (!opts.popover) {
const head = document.createElement("header");
head.className = "ui-float-panel__head";
head.dataset.size = "32";
const title = document.createElement("strong");
title.className = "ui-float-panel__title";
title.textContent = opts.title ?? "";
head.append(title);
if (opts.onClose)
head.append(
createIconButton({ icon: "x", title: "닫기", size: 24, onClick: () => opts.onClose?.() }),
);
if (opts.draggable) {
head.classList.add("is-draggable");
dragBy(root, head);
}
root.append(head);
if (opts.title) root.setAttribute("aria-label", opts.title);
}
const body = document.createElement("div");
body.className = "ui-float-panel__body";
root.append(body);
return { root, body };
}
+3 -2
View File
@@ -1,4 +1,5 @@
/* 본문 오른쪽 접는 패널 여럿 — 왼쪽 가장자리 손잡이(좌측 패널과 같은 꼴) · 편 패널 = 공유 폭(--ui-side-panels-width)
/* 오른쪽 서랍(552-4) — 흰 판 + 1 px 선 · 겉만 공통 값 · 자리 · 폭 · 열고 닫기 그대로
본문 오른쪽 접는 패널 여럿 — 왼쪽 가장자리 손잡이(좌측 패널과 같은 꼴) · 편 패널 = 공유 폭(--ui-side-panels-width)
보이는 칸 안에 붙음(JS followView) — 몸만 따로 스크롤 · 메인 스크롤과 따로 */
/* 부르는 쪽 칸 — [본문, 패널 묶음] */
@@ -82,7 +83,7 @@
height: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards);
background: var(--color-surface);
background: var(--color-surface-raised);
}
.ui-side-panels__panel[hidden] {
+9 -8
View File
@@ -1,13 +1,14 @@
/* =============================================================================
* ui_template_side_panels.ts
* 본문 오른쪽 접는 패널 여럿 — 나란히 · 하나만 열림 · 모두 접힘 됨 · 폭 공유
* 좌측 패널과 같은 접기 양식 — 패널 왼쪽 가장자리 손잡이(공용 createWorkflowPanelHandle · 오른쪽으로 접힘)
* 패널마다 손잡이 하나(이름표 세로) · 누르면 폄/접음 · 모두 접히면 손잡이만 남음
* 편 패널 = 머리(제목) + 몸 · 왼쪽 경계 끌기(화살표 키) · 보이는 칸 안에 붙어 몸만 따로 스크롤(followView)
* `follow: false`(85-5) = followView 안 씀 · 부르는 칸(고정 높이) 높이를 채움 · 메인 스크롤과 따로(M02 상세)
* 열린 패널 · 폭 = 부르는 쪽 취향 키(`aislo:pref:{prefKey}`)
* 부르기: const sp = createSidePanels({ prefKey, panels: [{ id, title, body }] }) →
* 본문과 함께 `.ui-side-panels-row` 칸에 [본문, sp.root] 로 넣음
* 오른쪽 서랍 `drawer`(552-4 틀) — 본문 오른쪽 접는 패널 여럿 · 나란히 · 하나만 열림 · 모두 접힘 됨 · 폭 공유
* 꼴 = 패널마다 손잡이 하나(왼쪽 가장자리 · 이름표 세로 · 공용 createWorkflowPanelHandle · 오른쪽으로 접힘)
* 편 패널 = 머리(제목 · 1 px 밑줄) + 몸 · 흰 판 + 1 px 선(그림자 없음) · 왼쪽 경계 끌기(화살표 키)
* 보이는 칸 안에 붙어 몸만 따로 스크롤(followView) · `follow: false`(85-5) = 부르는 칸(고정 높이)을 채움(M02 상세)
* 크기 = 폭 기본 420 · 가장 좁게 280 · 가장 넓게 화면의 85% · 폭 840 미만 = 본문 아래로 쌓음
* 상태 = 열림 · 접힘(`is-collapsed`) · 끄는 중(`is-resizing`) — 열린 패널 · 폭 = 부르는 쪽 취향 키(`aislo:pref:{prefKey}`)
* 쓰는 법 = const sp = createSidePanels({ prefKey, panels: [{ id, title, body }] }) →
* 본문과 함께 `.ui-side-panels-row` 칸에 [본문, sp.root] 로 넣음
* 대신하는 옛 변종 = M02 `m02-dfp`(옆 패널) · B01 채팅 판 `.b01-knowledge-chat__panel`
* ========================================================================== */
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
+41
View File
@@ -0,0 +1,41 @@
/* 도구줄(552-4) — 한 줄 · 사이 = --btn-group-gap · 높이 = data-size 단 · 유리 꼴은 그림 위 */
.ui-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--btn-group-gap);
min-width: 0;
}
.ui-toolbar[data-size="24"] {
min-height: 24px;
}
.ui-toolbar[data-size="28"] {
min-height: 28px;
}
.ui-toolbar[data-size="32"] {
min-height: 32px;
}
.ui-toolbar__divider {
flex: none;
align-self: stretch;
width: 1px;
margin: var(--spacing-4) var(--spacing-4);
background: var(--color-border);
}
.ui-toolbar__spacer {
flex: 1 1 auto;
}
.ui-toolbar--glass {
padding: var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: color-mix(in srgb, var(--color-surface-raised) 88%, transparent);
box-shadow: var(--shadow-sm);
backdrop-filter: blur(6px);
}
+48
View File
@@ -0,0 +1,48 @@
/* =============================================================================
* ui_template_toolbar.ts
* 도구줄 `toolbar`(552-4 틀) — 단추 · 찾기 · 고르기를 한 줄에 묶음 · 나눔선 · 오른쪽 끝 밀기.
* 꼴 = 보통(바탕 없음) · 유리(glass · 그림 위에 뜸) · 나눔선("divider") · 빈 칸 밀기("spacer")
* 크기 = data-size 24 · 28 · 32(기본 28) — 크기 단이 없는 단추 · 아이콘 단추 · 입력 · 고르기에 같은 단을 붙임
* 상태 = 줄 자체는 없음(안 단추가 제 상태를 가짐)
* 쓰는 법 = createToolbar({ items: [createIconButton({ icon: "search", title: "찾기" }), "divider", "spacer", saveBtn] })
* 대신하는 옛 변종 = `.ui-btn-group`(붙은 묶음은 그대로 씀) · `.ui-group-cards__bar` · `b05-routeedit__tools` ·
* `b04-map__controls` · `b08-hydro__toolbar` · `b09-estimation__tools` · `m01-logic__bar` · M02 `*__bar` · `*__tools` 약 20
* ========================================================================== */
import { setUiSize } from "./ui_template_button";
import "./ui_template_toolbar.css";
export type ToolbarSize = 24 | 28 | 32;
export type ToolbarItem = HTMLElement | "divider" | "spacer";
export interface ToolbarOptions {
items: ToolbarItem[];
size?: ToolbarSize;
/** 그림 위에 뜨는 유리 꼴 */
glass?: boolean;
/** 보조기기 이름 */
label?: string;
}
const SIZED = ".ui-btn, .ui-icon-btn, .ui-input, .ui-select";
export function createToolbar(opts: ToolbarOptions): HTMLElement {
const size = opts.size ?? 28;
const bar = document.createElement("div");
bar.className = `ui-toolbar${opts.glass ? " ui-toolbar--glass" : ""}`;
bar.setAttribute("role", "toolbar");
if (opts.label) bar.setAttribute("aria-label", opts.label);
bar.dataset.size = String(size);
for (const item of opts.items) {
if (typeof item === "string") {
const gap = document.createElement("span");
gap.className = `ui-toolbar__${item}`;
if (item === "divider") gap.setAttribute("role", "separator");
bar.append(gap);
continue;
}
if (!item.dataset.size && item.matches(SIZED)) setUiSize(item, size);
bar.append(item);
}
return bar;
}
+57
View File
@@ -4,12 +4,16 @@
* 견본 = 진짜 부품 함수 · 클래스로 그림 · 화면에 붙는(fixed) 틀은 칸 안에 놓이게 자리만 인라인으로 풂.
* ========================================================================== */
import { createIconButton } from "./ui_template_button";
import { createButton } from "./ui_template_elements";
import { createFloatPanel } from "./ui_template_float_panel";
import { createPageFrame } from "./ui_template_general_layout";
import { createListRow } from "./ui_template_list_row";
import { createWorkflowPanelHandle } from "./ui_template_overlay";
import { createPanelHead } from "./ui_template_panel_head";
import { createPublicFrame } from "./ui_template_public_layout";
import { createSidePanels } from "./ui_template_side_panels";
import { createToolbar } from "./ui_template_toolbar";
import type { KitState } from "./ui_template_ui_kit_parts";
import { createStepBar } from "./ui_template_workflow_layout";
@@ -150,11 +154,64 @@ function listRow(state: KitState): HTMLElement | null {
return box;
}
function floatPanel(): HTMLElement {
const place = (root: HTMLElement): HTMLElement => {
root.style.position = "relative";
return root;
};
const one = createFloatPanel({ title: "측정", onClose: noop, draggable: true, width: 220 });
one.body.append("거리 124.5 m · 기울기 8.2 %");
const pop = createFloatPanel({ popover: true, width: 160 });
pop.body.append("팝오버 — 머리 없음");
return wrap(place(one.root), place(pop.root));
}
function drawer(): HTMLElement {
const row = div("ui-side-panels-row");
row.style.cssText = "height:220px;width:100%";
const main = div("", "본문");
main.style.padding = "12px";
const body = (text: string): HTMLElement => div("", text);
const sp = createSidePanels({
prefKey: "ui-kit-demo",
follow: false,
defaultWidth: 280,
panels: [
{ id: "info", title: "상세", body: body("고른 줄의 값") },
{ id: "set", title: "설정", body: body("보기 설정") },
],
});
row.append(main, sp.root);
return row;
}
function toolbar(): HTMLElement {
const box = div("");
box.style.cssText = "display:flex;flex-direction:column;gap:12px;width:100%";
const items = () => [
createIconButton({ icon: "search", title: "찾기", onClick: noop }),
createIconButton({ icon: "adjustments", title: "보기", onClick: noop }),
"divider" as const,
createButton({ label: "더함", variant: "ghost", onClick: noop }),
"spacer" as const,
createButton({ label: "저장", onClick: noop }),
];
box.append(
createToolbar({ items: items(), size: 24 }),
createToolbar({ items: items() }),
createToolbar({ items: items().slice(0, 3), size: 32, glass: true }),
);
return box;
}
/** 열쇠 → 견본(상태별 · null = 그 상태 없음) */
export const FRAME_DEMOS: Record<string, (state: KitState) => HTMLElement | null> = {
"work-frame": (s) => (s === "default" ? workFrame() : null),
"page-frame": (s) => (s === "default" ? pageFrame() : null),
"public-frame": (s) => (s === "default" ? publicFrame() : null),
"float-panel": (s) => (s === "default" ? floatPanel() : null),
drawer: (s) => (s === "default" ? drawer() : null),
toolbar: (s) => (s === "default" ? toolbar() : null),
steps,
"panel-head": (s) => (s === "default" ? panelHead() : null),
"list-row": listRow,
+6 -3
View File
@@ -176,9 +176,12 @@ export const KIT_PARTS: KitPart[] = [
demo: FRAME_DEMOS["page-frame"] },
{ key: "public-frame", name: "공개 화면 틀", group: "frame", kinds: "머리 띠(제목 · 설명 · 주 단추) · 구역 · 본문", file: "ui_template_public_layout.ts",
demo: FRAME_DEMOS["public-frame"] },
{ key: "float-panel", name: "떠 있는 판", group: "frame", kinds: "그림 위 판 · 끌기 머리 · 닫기 · 팝오버", file: "ui_template_float_panel.ts" },
{ key: "drawer", name: "오른쪽 서랍", group: "frame", kinds: "상세 · 설정", file: "ui_template_side_panels.ts" },
{ key: "toolbar", name: "도구줄", group: "frame", kinds: "단추 묶음 · 나눔선 · 찾기 · 고르기", file: "ui_template_toolbar.ts" },
{ key: "float-panel", name: "떠 있는 판", group: "frame", kinds: "그림 위 판 · 끌기 머리 · 닫기 ✕ · 팝오버", file: "ui_template_float_panel.ts",
demo: FRAME_DEMOS["float-panel"] },
{ key: "drawer", name: "오른쪽 서랍", group: "frame", kinds: "상세 · 설정 · 폭 끌기 · 흰 판", file: "ui_template_side_panels.ts",
demo: FRAME_DEMOS["drawer"] },
{ key: "toolbar", name: "도구줄", group: "frame", kinds: "단추 묶음 · 나눔선 · 밀기 · 유리 · 24 · 28 · 32", file: "ui_template_toolbar.ts",
demo: FRAME_DEMOS["toolbar"] },
{ key: "steps", name: "단계 줄", group: "frame", kinds: "가로 막대 · compact(28) · 세로 목록(40 · ☰ 서랍)", file: "ui_template_workflow_layout.ts",
demo: FRAME_DEMOS.steps },
{ key: "panel-head", name: "패널 머리", group: "frame", kinds: "제목 · 부제 · 오른쪽 단추 · 밑줄", file: "ui_template_panel_head.ts",