Files
Aislo/ui_template/ui_template_overlay.ts
T
eomsangdonandClaude Opus 5 af85dea714 feat(ui_template): 진행단계 패널 끌어 옮기기·열림 방향 자동 전환
- 헤더를 잡아 끌어 자리 이동, 이동 범위는 상단 헤더 아래 화면 안으로 clamp
- 아래 공간 부족 시 `is-flip-up` 으로 헤더 위로 펼침(바닥 기준 정렬)
- 자리는 sessionStorage 세션 캐시에만 보관, 접힘/펴짐 뒤 방향 재판정
- threshold 4px 초과 시에만 포인터 캡처 — pointerdown 캡처는 click 대상을
  제목에서 헤더로 바꿔 접기/펴기 토글을 죽임(실측)
- 이동은 window 에서 청취 — B07 CAD iframe 위로 커서가 나가도 안 끊김

검증: 공용 브라우저 B03·B05·B07 실측(이동 오차 1px 이내, clamp·뒤집힘·토글 정상),
tsc --noEmit 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-04 06:37:57 +09:00

225 lines
8.9 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";
import { makePanelDraggable } from "./ui_template_overlay_drag";
const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open";
const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open";
const PROGRESS_OVERLAY_POSITION_KEY = "frd_workflow_progress_overlay_pos";
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 {
// 액션 줄은 보통 페이지 패널 루트(b05-route__panel 등) **안**에 있다 — body 직계가
// 아니므로 깊이 무관하게 찾고, 액션 줄의 실제 부모 기준으로 쪼갠다.
const actions = body.querySelector<HTMLElement>(".ui-sidebar-actions");
const container = actions?.parentElement;
if (!actions || !container) return;
const scroll = document.createElement("div");
scroll.className = "ui-sidebar-scroll";
// 액션 줄 앞의 형제만 옮긴다 — 뒤에 붙은 요소(없어야 정상)는 그대로 둔다.
while (container.firstChild && container.firstChild !== actions)
scroll.append(container.firstChild);
container.prepend(scroll);
body.classList.add("ui-sidebar-split");
// body → 부모 사이 중간 요소들이 높이를 이어주도록 표시한다(플렉스 세로 확장).
for (let el = container; el && el !== body; el = el.parentElement as HTMLElement) {
el.classList.add("ui-sidebar-fill");
}
}
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);
}
let dragHandle: { refresh: () => void } | null = null;
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));
// 펼친 뒤에는 아래 공간이 모자랄 수 있다 — 열리는 방향을 다시 잡는다.
if (dragHandle) requestAnimationFrame(() => dragHandle?.refresh());
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));
// 진행단계 패널만 사용자가 자리를 옮긴다(제목 패널은 좌측 도킹 사이드바).
if (variant === "progress") {
dragHandle = makePanelDraggable(root, header, PROGRESS_OVERLAY_POSITION_KEY);
}
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,
};
}