/* ============================================================================= * B01_Dashboard_UI_PageKit.ts * 대시보드 세부 페이지 공용 조각(549) — 페이지 머리 · 표 · 줄 끝 ⋯ · 배지 · 탭 · 빈 상태. * 색 · 간격 = 셸 변수(`--b01-*`) 만 · 모양은 `B01_Dashboard_UI_Style_Pages.css`. * 아이콘 중 `ui_template_icons` 에 없는 것(⋯ · + · 휴지통 · 상자)만 여기 · Tabler Icons(MIT) 선 모양. * ========================================================================== */ import { createButton, createTag, showConfirmDialog } from "@ui/ui_template_elements"; import { icon, type IconName } from "@ui/ui_template_icons"; import type { BadgeTone } from "./logic/B01_Dashboard_Logic_Pages"; import "./B01_Dashboard_UI_Style_Pages.css"; /** Tabler Icons(MIT · https://tabler.io/icons) — `ui_template_icons` 와 같은 24 × 24 · 선 2 */ const KIT_PATHS = { dots: "M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0|M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0", plus: "M12 5l0 14|M5 12l14 0", upload: "M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2 -2v-2|M7 9l5 -5l5 5|M12 4l0 12", mail: "M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10|M3 7l9 6l9 -6", inbox: "M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12|M4 13h3l3 3h4l3 -3h3", } as const; export type KitIconName = keyof typeof KIT_PATHS; export function kitIcon(name: KitIconName, size = 16): HTMLSpanElement { const span = document.createElement("span"); span.className = "ui-icon"; span.setAttribute("aria-hidden", "true"); const paths = KIT_PATHS[name] .split("|") .map((d) => ``) .join(""); span.innerHTML = `${paths}`; return span; } function node( tag: K, className: string, text?: string, ): HTMLElementTagNameMap[K] { const n = document.createElement(tag); n.className = className; if (text !== undefined) n.textContent = text; return n; } /** 배지 — 한글 글 + 색 */ export function badge(text: string, tone: BadgeTone = "neutral"): HTMLElement { const tag = createTag(text, tone); tag.classList.add("b01-pg-badge"); return tag; } /** 주 단추 — 오른쪽 위 하나 · 앞 아이콘 */ export function primaryButton(label: string, onClick: () => void, name: KitIconName = "plus") { return createButton({ label, onClick, iconStart: kitIcon(name) }); } export interface PageFrame { root: HTMLElement; /** 머리 아래 몸통 */ body: HTMLElement; /** 머리 오른쪽 주 단추 자리 */ setAction: (action: HTMLElement | null) => void; } /** 페이지 제목 + 한 줄 설명 + 오른쪽 위 주 단추 */ export function pageFrame(host: HTMLElement, title: string, desc: string): PageFrame { const root = node("div", "b01-pg"); const head = node("header", "b01-pg__head"); const text = node("div", "b01-pg__title-wrap"); text.append(node("h2", "b01-pg__title", title), node("p", "b01-pg__desc", desc)); const slot = node("div", "b01-pg__action"); head.append(text, slot); const body = node("div", "b01-pg__body"); root.append(head, body); host.append(root); return { root, body, setAction: (action) => slot.replaceChildren(...(action ? [action] : [])), }; } export type Cell = string | Node; export interface RowMenuItem { label: string; onClick: () => void; /** 삭제 · 빼기 = 빨강 + 확인창 */ danger?: boolean; /** 확인창 글(danger 일 때) */ confirm?: string; } /** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · 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) 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); }); 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; } export interface EmptyOptions { icon: IconName | KitIconName; text: string; hint?: string; action?: HTMLElement; /** 가이드 링크 — 누르면 */ onGuide?: () => void; } /** 빈 상태 — 선 아이콘 + 한 줄 + 주 단추 + 가이드 링크 */ export function emptyState(opts: EmptyOptions): HTMLElement { const box = node("div", "b01-pg-card b01-pg-empty"); const glyph = node("div", "b01-pg-empty__icon"); glyph.append( opts.icon in KIT_PATHS ? kitIcon(opts.icon as KitIconName, 32) : icon(opts.icon as IconName, 32), ); box.append(glyph, node("p", "b01-pg-empty__text", opts.text)); if (opts.hint) box.append(node("p", "b01-pg-empty__hint", opts.hint)); if (opts.action) box.append(opts.action); if (opts.onGuide) { const link = node("button", "b01-pg-link", "사용법 보기"); link.type = "button"; link.addEventListener("click", opts.onGuide); box.append(link); } return box; } export interface TabsHandle { root: HTMLElement; setBadge: (key: string, count: number) => void; } /** 탭 줄 — 눌러 바꿈 · 탭 오른쪽 수 배지(0 이면 숨김) */ export function tabBar( tabs: { key: string; label: string }[], initial: string, onChange: (key: string) => void, ): TabsHandle { const root = node("div", "b01-pg-tabs"); root.setAttribute("role", "tablist"); const badges = new Map(); const buttons = new Map(); const select = (key: string): void => { for (const [k, b] of buttons) { b.classList.toggle("is-active", k === key); b.setAttribute("aria-selected", String(k === key)); } }; for (const tab of tabs) { const b = node("button", "b01-pg-tabs__tab"); b.type = "button"; b.setAttribute("role", "tab"); b.dataset.tab = tab.key; const count = node("span", "b01-pg-tabs__count"); count.hidden = true; b.append(node("span", "", tab.label), count); b.addEventListener("click", () => { select(tab.key); onChange(tab.key); }); buttons.set(tab.key, b); badges.set(tab.key, count); root.append(b); } select(initial); return { root, setBadge: (key, n) => { const c = badges.get(key); if (!c) return; c.textContent = String(n); c.hidden = n <= 0; }, }; } /** 설정 카드 한 줄(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"); cell.append(node("span", "b01-pg-name__main", name)); 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; }