Files
Aislo/B01_Dashboard/B01_Dashboard_UI_PageKit.ts
T

294 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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) => `<path d="${d}"/>`)
.join("");
span.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths}</svg>`;
return span;
}
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;
}
/** 줄 끝 ⋯ — 열면 항목 · 밖을 누르면 닫힘 · 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<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;
}