Files
Aislo/ui_template/ui_template_overlay.ts
T
eomsangdonandClaude Opus 5 a794252e2e refactor(ui): 사이드 패널을 [스크롤 영역][고정 액션 영역]으로 분리
sticky + margin-top:auto 조합은 본문 높이가 패널 높이를 넘나들 때마다 액션 줄이
붙었다 떨어졌다 했다(2026-08-18 사용자 보고). 액션 줄을 스크롤 대상에서 아예 빼는
구조로 바꾼다.

- overlay.ts: 사이드 패널 생성 시 본문의 `.ui-sidebar-actions` 앞 형제들을
  마진·패딩 없는 `.ui-sidebar-scroll` 래퍼로 옮기고 body에 `ui-sidebar-split`을
  붙인다. 페이지 코드는 지금처럼 본문 끝에 액션 줄을 붙이기만 하면 된다.
- overlay.css: split body = 세로 플렉스 + overflow hidden + padding-bottom 0,
  스크롤 래퍼 = flex:1/min-height:0/overflow-y:scroll + gap:inherit(그룹 간격 유지),
  액션 줄 = flex:none(sticky·margin-top 제거).
- B08: 전용 sticky 규칙을 버리고 공용 ui-sidebar-actions로 통합 — B04~B08이 한 규칙.

스크롤바 자리 상시 예약(2026-08-17)은 래퍼가 이어받아 내용 폭은 그대로다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 17:21:40 +09:00

207 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "./ui_template_overlay.css";
import { t } from "./ui_template_locale";
const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open";
const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open";
export interface WorkflowOverlayOptions {
title: string;
progressContent: HTMLElement;
optionsContent?: HTMLElement;
showTitlePanel?: boolean;
onOptionsOpenChange?: (isOpen: boolean) => void;
}
export interface WorkflowOverlayHandle {
root: HTMLElement;
setTitleOpen: (isOpen: boolean) => void;
setProgressOpen: (isOpen: boolean) => void;
}
export interface WorkflowPanelHandle {
root: HTMLButtonElement;
setOpen: (isOpen: boolean) => void;
}
export function createWorkflowPanelHandle(
placement: "side" | "bottom",
/**
* 패널이 접히면서 사라지는 쪽. 화살표가 항상 그 방향을 가리키게 만든다.
*
* 기본값 `"left"`는 왼쪽에 도킹해 왼쪽으로 접히는 패널(제목 카드)용이다.
* 오른쪽에 도킹해 오른쪽으로 접히는 패널(B05 배수유역도)은 `"right"`를 넘겨야
* 화살표가 뒤집히지 않는다 — 도킹 방향이 반대인데 같은 회전을 쓰면 열기/닫기
* 표시가 정확히 거꾸로 보인다.
*
* `placement: "bottom"`은 위아래로 접히는 패널이다. 기본은 아래로 접히는 하단 도킹
* 패널(`"down"`)이고, **위로 접히는 상단 도킹 패널**(B06 종단·유토곡선 패널)은 `"up"`을
* 넘겨야 한다. 좌우와 같은 이유로, 도킹 방향이 반대인데 같은 회전을 쓰면 표시가 거꾸로 된다.
*/
collapseToward: "left" | "right" | "up" | "down" = "left",
/**
* 손잡이 옆에 붙일 짧은 이름표(선택, 2026-08-04 사용자 지시 — 무엇의 손잡이인지 보이게).
* bottom 손잡이는 화살표 **오른쪽**에 가로로, side 손잡이는 화살표 **위쪽**에 90° 세로로
* 놓인다. 사이드바(제목 패널)처럼 이름이 필요 없는 곳은 생략하면 현행과 같다.
*/
label?: string,
): WorkflowPanelHandle {
const root = document.createElement("button");
root.type = "button";
root.className = `ui-workflow-overlay__handle ui-workflow-overlay__handle--${placement}`;
const icon = document.createElement("span");
icon.className = "ui-workflow-overlay__handle-icon";
if (label) {
root.classList.add("is-labeled");
const text = document.createElement("span");
text.className = "ui-workflow-overlay__handle-label";
text.textContent = label;
// side는 이름표가 화살표 위, bottom은 화살표 오른쪽 — DOM 순서로 자리를 정한다.
if (placement === "side") root.append(text, icon);
else root.append(icon, text);
} else {
root.append(icon);
}
function setOpen(isOpen: boolean): void {
// 삼각형은 회전 0°에서 오른쪽(▶)을 가리킨다. 펼친 상태면 접히는 쪽을,
// 접힌 상태면 펼쳐질 쪽을 가리키도록 90°/180°를 붙였다 뗀다.
const pointsRight = isOpen === (collapseToward === "right");
const pointsUp = isOpen === (collapseToward === "up");
const rotation = placement === "side" ? (pointsRight ? 0 : 180) : pointsUp ? -90 : 90;
icon.style.transform = `rotate(${rotation}deg)`;
root.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand");
root.setAttribute("aria-label", root.title);
root.setAttribute("aria-expanded", String(isOpen));
}
return { root, setOpen };
}
function readOpenState(key: string): boolean {
return sessionStorage.getItem(key) !== "false";
}
/**
* 사이드 패널 본문을 [스크롤 영역][고정 액션 영역] 두 칸으로 쪼갠다.
*
* 액션 줄(`.ui-sidebar-actions`)을 sticky로만 붙여 두면 본문 높이가 패널 높이를
* 넘나들 때마다 자리가 흔들린다(2026-08-18 사용자 보고). 액션 줄을 스크롤 대상에서
* 아예 빼고, 나머지 그룹만 마진·패딩 없는 래퍼(`.ui-sidebar-scroll`)에 담아 그쪽만
* 스크롤시킨다. 페이지는 지금처럼 본문 마지막에 액션 줄을 붙이기만 하면 된다.
*/
function splitSidebarActions(body: HTMLElement): void {
const actions = body.querySelector<HTMLElement>(":scope > .ui-sidebar-actions");
if (!actions) return;
const scroll = document.createElement("div");
scroll.className = "ui-sidebar-scroll";
// 액션 줄 앞의 형제만 옮긴다 — 뒤에 붙은 요소(없어야 정상)는 그대로 둔다.
while (body.firstChild && body.firstChild !== actions) scroll.append(body.firstChild);
body.prepend(scroll);
body.classList.add("ui-sidebar-split");
}
function createPanel(
variant: "title" | "progress",
titleText: string,
body: HTMLElement,
storageKey: string,
onOpenChange?: (isOpen: boolean) => void,
): { root: HTMLElement; setOpen: (isOpen: boolean) => void } {
const root = document.createElement("aside");
root.className = `ui-workflow-overlay__panel ui-workflow-overlay__panel--${variant}`;
// title 패널은 사이드바 도킹형 — 토글이 패널 우측 가장자리 세로 중앙 핸들로 나감
const isSidebar = variant === "title";
const header = document.createElement("div");
header.className = "ui-workflow-overlay__header";
const title = document.createElement("h2");
title.className = "ui-workflow-overlay__title";
title.textContent = titleText;
const panelHandle = isSidebar ? createWorkflowPanelHandle("side") : null;
const toggle = panelHandle?.root ?? document.createElement("button");
if (!panelHandle) {
toggle.type = "button";
toggle.className = "ui-workflow-overlay__toggle";
}
if (isSidebar) {
header.append(title);
root.append(header, toggle);
} else {
header.append(title, toggle);
root.append(header);
}
if (body.childElementCount > 0) {
body.classList.add("ui-workflow-overlay__body");
if (isSidebar) splitSidebarActions(body);
root.append(body);
}
function setOpen(isOpen: boolean): void {
root.classList.toggle("is-collapsed", !isOpen);
if (panelHandle) panelHandle.setOpen(isOpen);
else {
toggle.textContent = isOpen ? "−" : "+";
toggle.title = t(isOpen ? "Workflow_Overlay_Collapse" : "Workflow_Overlay_Expand");
toggle.setAttribute("aria-label", toggle.title);
toggle.setAttribute("aria-expanded", String(isOpen));
}
sessionStorage.setItem(storageKey, String(isOpen));
onOpenChange?.(isOpen);
}
function toggleOpen(): void {
setOpen(root.classList.contains("is-collapsed"));
}
title.tabIndex = 0;
title.setAttribute("role", "button");
title.addEventListener("click", toggleOpen);
title.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
toggleOpen();
}
});
toggle.addEventListener("click", toggleOpen);
setOpen(readOpenState(storageKey));
return { root, setOpen };
}
export function createWorkflowOverlays(options: WorkflowOverlayOptions): WorkflowOverlayHandle {
const root = document.createElement("div");
root.className = "ui-workflow-overlay";
const progressBody = document.createElement("div");
progressBody.append(options.progressContent);
const progressPanel = createPanel(
"progress",
t("Workflow_Progress_Title"),
progressBody,
PROGRESS_OVERLAY_STATE_KEY,
);
let setTitleOpen = (_isOpen: boolean): void => {};
if (options.showTitlePanel !== false) {
const titleBody = document.createElement("div");
if (options.optionsContent) titleBody.append(options.optionsContent);
const titlePanel = createPanel(
"title",
options.title,
titleBody,
TITLE_OVERLAY_STATE_KEY,
options.onOptionsOpenChange,
);
root.append(titlePanel.root);
setTitleOpen = titlePanel.setOpen;
}
root.append(progressPanel.root);
return {
root,
setTitleOpen,
setProgressOpen: progressPanel.setOpen,
};
}