- ui_template_button.ts · .css 새로 — createButton 을 옮김(ui_template_elements 가 다시 내보냄 · 부르는 길 그대로) · createIconButton 새로(고스트 · 선 · 채움 · aria-pressed · 글자 기호 · title + aria-label) - 크기 = data-size 18 · 20 · 22 · 24 · 28 · 32 · 36 · 42 · 단마다 높이 · 글자 · 여백 · 아이콘 · 둥글기 값(--ui-size-*) · 입력 · 고르기 · 분할 · 탭이 같은 값을 읽음 - 단추 높이 = 글 한 줄 + 여백 + 테두리(높이를 박지 않음) — 크기를 안 준 옛 단추 · 페이지 여백 덮어쓰기 그대로 · 옛 size "sm" = 18 단(같은 꼴) · 글 링크 꼴 더함 - 상태 = 마우스 · 눌림을 data-state 로도 받음(부품 모음이 굳혀 보임) · 마우스 색 = --color-primary-hover · --color-accent-soft(옛 보라 별칭 대신) · 눌림 = 한 칸 내려앉음 - 아이콘 = Tabler 32 더함(지도 · 길 · 자 · 합 · 크레인 · 핀 · 나침반 · 설정 · 닫기 · 메뉴 · 되돌리기 …) + PageKit 여섯을 옮김 · .ui-icon 규칙 전역(ui_template_icons.css) - 부품 모음 = 단추 · 아이콘 단추 · 아이콘 견본 · 부품마다 여덟 단 한 줄 - 시험 test_552_2_button 새로(단 값 · 높이 식 · 옛 이름 · 상태 · 날 색 없음 · 아이콘) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
279 lines
9.5 KiB
TypeScript
279 lines
9.5 KiB
TypeScript
/* =============================================================================
|
|
* B01_Dashboard_UI_PageKit.ts
|
|
* 대시보드 세부 페이지 공용 조각(549) — 페이지 머리 · 표 · 줄 끝 ⋯ · 배지 · 탭 · 빈 상태.
|
|
* 색 · 간격 = 셸 변수(`--b01-*`) 만 · 모양은 `B01_Dashboard_UI_Style_Pages.css`.
|
|
* 아이콘 = `ui_template_icons`(552-2 에 여섯을 옮김).
|
|
* ========================================================================== */
|
|
|
|
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";
|
|
|
|
/** 아이콘 여섯 — 552-2 에 `ui_template_icons` 로 옮김(같은 Tabler 선) · 이름 · 기본 크기 16 그대로 */
|
|
export type KitIconName = Extract<
|
|
IconName,
|
|
"dots" | "plus" | "pencil" | "upload" | "mail" | "inbox"
|
|
>;
|
|
|
|
export function kitIcon(name: KitIconName, size = 16): HTMLSpanElement {
|
|
return icon(name, size);
|
|
}
|
|
|
|
function node<K extends keyof HTMLElementTagNameMap>(
|
|
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;
|
|
/** 누르면 열리는 모달이 확인을 맡음 — 빨강만 · 확인창 없음 */
|
|
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;
|
|
}
|
|
|
|
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(icon(opts.icon, 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<string, HTMLElement>();
|
|
const buttons = new Map<string, HTMLButtonElement>();
|
|
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;
|
|
}
|