diff --git a/resources/tester/test_552_4_frames.py b/resources/tester/test_552_4_frames.py index 70770bfd8..6fddbe9f6 100644 --- a/resources/tester/test_552_4_frames.py +++ b/resources/tester/test_552_4_frames.py @@ -22,6 +22,9 @@ PARTS = { "work-frame": ("ui_template_overlay", "createWorkflowOverlays", "ui-workflow-overlay", ()), "page-frame": ("ui_template_general_layout", "createPageFrame", "ui-page-frame", ()), "public-frame": ("ui_template_public_layout", "createPublicFrame", "ui-public-frame", ()), + "float-panel": ("ui_template_float_panel", "createFloatPanel", "ui-float-panel", (32,)), + "drawer": ("ui_template_side_panels", "createSidePanels", "ui-side-panels", ()), + "toolbar": ("ui_template_toolbar", "createToolbar", "ui-toolbar", (24, 28, 32)), "steps": ("ui_template_workflow_layout", "createStepBar", "ui-workflow-layout__step", (28, 40)), "panel-head": ("ui_template_panel_head", "createPanelHead", "ui-panel-head", (32,)), "list-row": ("ui_template_list_row", "createListRow", "ui-list-row", (28, 32, 36)), @@ -64,7 +67,7 @@ def test_날_색_없음(key): def test_상태_규칙(): # 마우스 · 눌림을 data-state 로 굳혀 보일 수 있음 - for stem in ("ui_template_list_row", "ui_template_workflow_layout"): + for stem in ("ui_template_list_row", "ui_template_workflow_layout", "ui_template_overlay"): css = _text(UI / f"{stem}.css") assert ':is(:hover, [data-state="hover"])' in css, stem row = _text(UI / "ui_template_list_row.css") diff --git a/ui_template/ui_template_float_panel.css b/ui_template/ui_template_float_panel.css new file mode 100644 index 000000000..63f49804a --- /dev/null +++ b/ui_template/ui_template_float_panel.css @@ -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; +} diff --git a/ui_template/ui_template_float_panel.ts b/ui_template/ui_template_float_panel.ts new file mode 100644 index 000000000..408fc4277 --- /dev/null +++ b/ui_template/ui_template_float_panel.ts @@ -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 }; +} diff --git a/ui_template/ui_template_side_panels.css b/ui_template/ui_template_side_panels.css index 9b1d400d0..37f2717a2 100644 --- a/ui_template/ui_template_side_panels.css +++ b/ui_template/ui_template_side_panels.css @@ -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] { diff --git a/ui_template/ui_template_side_panels.ts b/ui_template/ui_template_side_panels.ts index 539aeb30f..c5a68935e 100644 --- a/ui_template/ui_template_side_panels.ts +++ b/ui_template/ui_template_side_panels.ts @@ -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"; diff --git a/ui_template/ui_template_toolbar.css b/ui_template/ui_template_toolbar.css new file mode 100644 index 000000000..a02961e15 --- /dev/null +++ b/ui_template/ui_template_toolbar.css @@ -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); +} diff --git a/ui_template/ui_template_toolbar.ts b/ui_template/ui_template_toolbar.ts new file mode 100644 index 000000000..87841f4e2 --- /dev/null +++ b/ui_template/ui_template_toolbar.ts @@ -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; +} diff --git a/ui_template/ui_template_ui_kit_frames.ts b/ui_template/ui_template_ui_kit_frames.ts index 8e0298888..0681ea42d 100644 --- a/ui_template/ui_template_ui_kit_frames.ts +++ b/ui_template/ui_template_ui_kit_frames.ts @@ -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 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, diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index 3739e7361..6a2e21018 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -146,9 +146,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",