- 여백 8px 제거 · 칸 바닥 넘지 않음 · 칸 위가 화면 아래면 칸 위부터 - 시험: ui_checks/51-1_오른쪽패널_높이.js Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK
214 lines
8.4 KiB
TypeScript
214 lines
8.4 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 place = (): void => {
|
|
const root = ref.deref();
|
|
if (!root) return stop();
|
|
if (!root.isConnected) return;
|
|
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);
|
|
};
|
|
window.addEventListener("scroll", place, true);
|
|
window.addEventListener("resize", place);
|
|
setTimeout(place); // 붙은 뒤 한 번
|
|
}
|