Files
Aislo/ui_template/ui_template_side_panels.ts
T
eomsangdonandClaude Opus 5.5 cbe0c17505 feat(ui): 47-3 공용 오른쪽 접는 패널 부품 — 패널 여럿 나란히 · 하나만 열림 · 폭 공유
- createSidePanels({ prefKey, panels: [{ id, title, body }] }) · 본문과 함께 .ui-side-panels-row 칸에 [본문, root]
- 접힌 패널 = 제목 세로 글 띠(누르면 폄) · 편 패널 = 머리(제목 · 접기 ▸) + 몸 · 모두 접힘 됨
- 왼쪽 경계 끌기 · 화살표 키로 폭 · 열린 패널 · 폭은 취향 키 aislo:pref:{prefKey}
- 폭 자르기 시험(test_ui_side_panels)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
2026-10-05 13:36:25 +09:00

183 lines
6.3 KiB
TypeScript

/* =============================================================================
* ui_template_side_panels.ts
* 본문 오른쪽 접는 패널 여럿 — 나란히 · 하나만 열림 · 모두 접힘 됨 · 폭 공유
* 접힌 패널 = 제목 세로 글 띠(누르면 폄) · 편 패널 = 머리(제목 · 접기 ▸) + 몸 · 왼쪽 경계 끌기(화살표 키)
* 열린 패널 · 폭 = 부르는 쪽 취향 키(`aislo:pref:{prefKey}`)
* 부르기: const sp = createSidePanels({ prefKey, panels: [{ id, title, body }] }) →
* 본문과 함께 `.ui-side-panels-row` 칸에 [본문, sp.root] 로 넣음
* ========================================================================== */
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { el } from "./ui_template_elements";
import { currentLanguageIndex } from "./ui_template_locale";
import "./ui_template_side_panels.css";
const TEXT = {
Open: ["{v} 펴기", "Open {v}"],
Close: ["{v} 접기", "Collapse {v}"],
Grip: ["패널 너비 — 끌거나 화살표 키", "Panel width — drag or arrow keys"],
} as const;
const ts = (key: keyof typeof TEXT, v: string): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
export interface SidePanelDef {
id: string;
title: string;
body: HTMLElement;
}
export interface SidePanelsOpts {
panels: SidePanelDef[];
/** 취향 키 이름 — `aislo:pref:` 뒤에 붙음 */
prefKey: string;
/** 기억이 없을 때 열 패널(null = 모두 접힘) · 기본 첫 패널 */
defaultOpen?: string | null;
/** 기억이 없을 때 폭(px) · 기본 420 */
defaultWidth?: number;
/** 가장 좁은 폭(px) · 기본 280 · 가장 넓게 = 화면의 85% */
minWidth?: number;
/** 열린 패널이 바뀜(누름 · open 모두) */
onChange?(open: string | null): void;
/** 폭이 바뀜(끌기 · 화살표) */
onResize?(px: number): void;
}
export interface SidePanels {
root: HTMLElement;
/** 열 패널(null = 모두 접음) — 기억함 */
open(id: string | null): void;
current(): string | null;
width(): number;
}
interface Pref {
open?: string | null;
width?: number;
}
/** 끌어 정한 폭 — 가장 작게 min · 가장 크게 화면의 85% */
export function clampSideWidth(px: number, min: number, viewport: number): number {
return Math.round(Math.min(Math.max(min, viewport * 0.85), Math.max(min, px)));
}
export function createSidePanels(opts: SidePanelsOpts): SidePanels {
const key = `aislo:pref:${opts.prefKey}`;
const min = opts.minWidth ?? 280;
const ids = opts.panels.map((p) => p.id);
let pref: Pref = {};
try {
pref = JSON.parse(readByKey(key) ?? "{}") as Pref;
} catch {
/* 깨진 기억 — 기본값 */
}
let now: string | null =
pref.open === null || ids.includes(pref.open ?? "")
? (pref.open ?? null)
: opts.defaultOpen === undefined
? (ids[0] ?? null)
: opts.defaultOpen;
let width = clampSideWidth(pref.width ?? opts.defaultWidth ?? 420, min, window.innerWidth);
const save = (): void => writeByKey(key, JSON.stringify({ open: now, width }));
const root = el("div", { className: "ui-side-panels" });
root.style.setProperty("--ui-side-panels-width", `${width}px`);
const setWidth = (px: number, keep: boolean): void => {
width = clampSideWidth(px, min, window.innerWidth);
root.style.setProperty("--ui-side-panels-width", `${width}px`);
opts.onResize?.(width);
if (keep) save();
};
const views = opts.panels.map((p) => {
const bodyId = `ui-side-panel-${opts.prefKey}-${p.id}`;
const strip = el("button", {
className: "ui-side-panels__strip",
text: p.title,
attrs: { type: "button", "aria-expanded": "false", "aria-controls": bodyId },
});
strip.title = ts("Open", p.title);
strip.addEventListener("click", () => {
open(p.id);
fold.focus();
});
const fold = el("button", {
className: "ui-side-panels__fold",
text: "▸",
attrs: { type: "button", "aria-expanded": "true", "aria-controls": bodyId },
});
fold.title = ts("Close", p.title);
fold.setAttribute("aria-label", fold.title);
fold.addEventListener("click", () => {
open(null);
strip.focus();
});
const grip = el("div", {
className: "ui-side-panels__grip",
attrs: {
role: "separator",
"aria-orientation": "vertical",
"aria-label": ts("Grip", ""),
tabindex: "0",
},
});
const box = el("section", {
className: "ui-side-panels__panel",
attrs: { "data-panel": p.id, "aria-label": p.title },
children: [
grip,
el("header", {
className: "ui-side-panels__head",
children: [el("strong", { className: "ui-side-panels__title", text: p.title }), fold],
}),
el("div", { className: "ui-side-panels__body", attrs: { id: bodyId }, children: [p.body] }),
],
});
grip.addEventListener("pointerdown", (ev) => {
if (ev.button !== 0) return;
ev.preventDefault();
const start = box.getBoundingClientRect().width;
const x0 = ev.clientX;
root.classList.add("is-resizing");
const move = (m: PointerEvent): void => setWidth(start + x0 - m.clientX, false);
const up = (u: PointerEvent): void => {
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
root.classList.remove("is-resizing");
setWidth(start + x0 - u.clientX, true);
};
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
});
grip.addEventListener("keydown", (ev) => {
const step = ev.shiftKey ? 80 : 20;
const d = ev.key === "ArrowLeft" ? step : ev.key === "ArrowRight" ? -step : 0;
if (!d) return;
ev.preventDefault();
setWidth(width + d, true);
});
root.append(strip, box);
return { id: p.id, strip, box };
});
const draw = (): void => {
for (const v of views) {
const on = v.id === now;
v.strip.hidden = on;
v.box.hidden = !on;
}
};
function open(id: string | null): void {
const next = id !== null && ids.includes(id) ? id : null;
const was = now;
now = next;
draw();
save();
if (next !== was) opts.onChange?.(next);
}
draw();
return { root, open, current: () => now, width: () => width };
}