/* ============================================================================= * 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 }; }