- 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
183 lines
6.3 KiB
TypeScript
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 };
|
|
}
|