Files
Aislo/ui_template/ui_template_side_panels.ts
T
eomsangdonandClaude Opus 5.5 ae04b403d6 fix(ui): 51-8 오른쪽 패널 높이 — 칸 크기가 바뀌면 다시 잼(M01 로직 상세 240 고정 해소)
- 원인: 패널 높이를 붙은 직후 한 번만 잼 · M01 은 내용이 늦게 차 그때 칸이 짧아 최소 240 · 스크롤·창 크기 바뀜 전까지 그대로
- 고침: 패널 · 부모 칸 크기 지켜봄(ResizeObserver) — 다시 붙음 · 내용 늦게 참에도 다시 잼
- 51-1 확인: 스크롤 알림 없는 「그대로」 줄 추가(알림이 흠을 가림)

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

224 lines
8.8 KiB
TypeScript

/* =============================================================================
* ui_template_side_panels.ts
* 본문 오른쪽 접는 패널 여럿 — 나란히 · 하나만 열림 · 모두 접힘 됨 · 폭 공유
* 좌측 패널과 같은 접기 양식 — 패널 왼쪽 가장자리 손잡이(공용 createWorkflowPanelHandle · 오른쪽으로 접힘)
* 패널마다 손잡이 하나(이름표 세로) · 누르면 폄/접음 · 모두 접히면 손잡이만 남음
* 편 패널 = 머리(제목) + 몸 · 왼쪽 경계 끌기(화살표 키) · 보이는 칸 안에 붙어 몸만 따로 스크롤(followView)
* 열린 패널 · 폭 = 부르는 쪽 취향 키(`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 { createWorkflowPanelHandle } from "./ui_template_overlay";
import "./ui_template_side_panels.css";
const TEXT = {
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 handles = el("div", { className: "ui-side-panels__handles" });
root.append(handles);
const views = opts.panels.map((p) => {
const bodyId = `ui-side-panel-${opts.prefKey}-${p.id}`;
const handle = createWorkflowPanelHandle("side", "right", p.title);
handle.root.setAttribute("aria-controls", bodyId);
handle.root.dataset.panel = p.id;
handle.root.addEventListener("click", () => open(now === p.id ? null : p.id));
handles.append(handle.root);
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 })],
}),
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(box);
return { id: p.id, handle, box };
});
const draw = (): void => {
for (const v of views) {
const on = v.id === now;
v.handle.setOpen(on);
v.box.hidden = !on;
}
root.classList.toggle("is-collapsed", now === null);
};
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();
followView(root);
return { root, open, current: () => now, width: () => width };
}
/** 가장 가까운 스크롤 칸(없으면 창) */
function scroller(node: HTMLElement): HTMLElement | null {
for (let n = node.parentElement; n; n = n.parentElement) {
const y = getComputedStyle(n).overflowY;
if ((y === "auto" || y === "scroll") && n.scrollHeight > n.clientHeight + 1) return n;
}
return null;
}
/**
* 패널 묶음을 보이는 칸 안에 붙임 — 높이 = 보이는 창 높이 − 머리띠(칸이 아래에 있으면 칸 위부터) · 본문을 굴려도 그 자리(몸만 따로 스크롤)
* CSS sticky 는 위 칸의 overflow 가 막아 JS 로 — 어느 칸이 굴러도(capture) 다시 잼
*/
function followView(node: HTMLElement): void {
const ref = new WeakRef(node); // 떼었다 다시 붙이는 화면(M01)도 있어 떼어져도 듣기는 남김 · 버려지면 그침
// 다시 붙음 · 칸 내용이 늦게 차 칸 크기가 바뀜(스크롤 없이) → 다시 잼
const sized = new ResizeObserver(() => place());
let watched: Element | null = null;
const place = (): void => {
const root = ref.deref();
if (!root) return stop();
if (!root.isConnected) return;
if (root.parentElement && root.parentElement !== watched) {
if (watched) sized.unobserve(watched);
watched = root.parentElement;
sized.observe(watched);
}
const box = scroller(root)?.getBoundingClientRect();
const head = document.querySelector(".app-header")?.getBoundingClientRect().bottom ?? 0;
const view = Math.max(head, box?.top ?? 0);
const bottom = Math.min(window.innerHeight, box?.bottom ?? window.innerHeight);
const row = root.parentElement?.getBoundingClientRect();
if (!row) return;
// 칸이 아직 화면 아래에 있으면 칸 위에 맞춤 · 올라가 붙으면 보이는 칸 전체 높이
const top = Math.max(view, row.top);
// 칸 바닥(+ 쪽 아래 여백)을 넘지 않음 · 너무 줄면 최소 높이로 두고 칸 바닥에 맞춰 올라감
const height = Math.max(240, Math.min(bottom, row.bottom + 48) - top);
root.style.height = `${height}px`;
const shift = Math.min(Math.max(0, view - row.top), Math.max(0, row.height + 48 - height));
root.style.transform = shift ? `translateY(${Math.round(shift)}px)` : "";
};
const stop = (): void => {
window.removeEventListener("scroll", place, true);
window.removeEventListener("resize", place);
sized.disconnect();
};
window.addEventListener("scroll", place, true);
window.addEventListener("resize", place);
sized.observe(node);
setTimeout(place); // 붙은 뒤 한 번
}