Files
Aislo/ui_template/ui_template_overlay.ts
T
eomsangdonandClaude Opus 5 a552bfd444 fix(ui): 아주 좁은 폭에서는 좌패널을 줄이지 말고 접은 채로 시작
⚠ 앞 커밋의 「본문에 320 을 남긴다」가 폭 500 에서 패널을 **180px 로 눌러** 목록
이름이 잘렸음(랩탑 보조 실측: 잘린 이름 2건). 폭을 반씩 나누면 **둘 다 못 쓰는
화면**이 됨.

- CSS ≤560 — 패널을 **안 줄임**(280px 유지). 본문 밀기는 이미 ≤860 에서 꺼져 있어
  패널이 **본문 위에 겹쳐** 뜨고, 읽을 때는 손잡이로 접음. 폭 500 에서 잘린 글자 0.
- TS — 정한 적이 없고 화면이 아주 좁으면(≤560) **접은 채로 시작**함. 펼친 채로 열면
  본문이 가려진 화면부터 보게 되기 때문임.
- ⚠ **사용자가 정한 적이 있으면 그 뜻이 이김** — 저장값이 있으면 그대로 따름.
- ⚠ **처음 앉힐 때는 저장하지 않음** — 좁아서 접은 것을 「사용자가 접었다」로 적으면
  넓은 화면으로 돌아와도 접힌 채로 남음.

실측
  정한 적 없음 + 500  → 접힘 true · 저장값 null (기록 안 남김)
  정한 적 없음 + 1400 → 접힘 false · 저장값 null
  「펼침」 정해 둠 + 500 → 접힘 false (사용자 뜻이 이김)
  잘린 글자 860·620·560·500 전부 0

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

274 lines
12 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 { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
import { t } from "./ui_template_locale";
import { makePanelDraggable } from "./ui_template_overlay_drag";
// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을
// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다.
import { fetchWorkflowState } from "../A00_Common/b_workflow_nav";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
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 };
}
/** 좌측 패널을 **접은 채로 시작**하는 폭 — `ui_template_overlay.css` 의 560 과 짝이다. */
const NARROW_START_COLLAPSED_MAX_PX = 560;
/**
* 처음 열 때 펼쳐 둘지.
*
* ⚠ **사용자가 정한 적이 있으면 그 뜻이 이긴다** — 저장값이 있으면 그대로 따른다.
* 정한 적이 없고 화면이 아주 좁으면 **접은 채로 시작**한다. 그 폭에서는 패널이 본문
* 위에 겹쳐 떠서, 펼친 채로 열면 본문이 가려진 화면부터 보게 된다(2026-09-08).
*/
function readOpenState(key: string, collapseWhenNarrow = false): boolean {
const raw = readByKey(key);
if (raw !== null) return raw !== "false";
if (!collapseWhenNarrow) return true;
return window.innerWidth > NARROW_START_COLLAPSED_MAX_PX;
}
/**
* 사이드 패널 본문을 [스크롤 영역][고정 액션 영역] 두 칸으로 쪼갠다.
*
* 액션 줄(`.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");
}
}
/**
* 제목 줄 오른쪽에 붙는 프로젝트 이름 (2026-09-04 사용자 지시).
*
* 화면이 들고 있는 것은 프로젝트 id 뿐이라 워크플로 상태 응답에서 이름을 받아 온다 —
* 새로고침·주소 직접 입력으로 들어와도 따라온다. 이름이 없으면 칸을 비워 둔다.
* 여기 한 곳에 두어 제목 패널을 쓰는 화면(B03~B08)이 모두 같은 값을 보인다.
*/
function createProjectNameTag(): HTMLElement {
const tag = document.createElement("span");
tag.className = "ui-workflow-overlay__project";
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) return tag;
void fetchWorkflowState(projectId)
.then((state) => {
const name = state.project_name ?? "";
tag.textContent = name;
// 이름이 길면 말줄임으로 자르고 전체는 툴팁으로 본다.
if (name) tag.title = name;
})
.catch(() => {
/* 조회 실패는 제목만 보이면 된다 — 빈 칸으로 둔다. */
});
return tag;
}
function createPanel(
variant: "title" | "progress",
titleText: string,
body: HTMLElement,
storageKey: string,
onOpenChange?: (isOpen: boolean) => void,
titleAside?: HTMLElement,
): { 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);
// 제목은 왼쪽, 프로젝트 이름은 같은 행 오른쪽 끝 (2026-09-04 사용자 지시).
if (titleAside) header.append(titleAside);
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, persist = true): 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));
}
// ⚠ 처음 앉힐 때는 저장하지 않는다 — 좁아서 접어 둔 것을 「사용자가 접었다」로
// 적으면 넓은 화면으로 돌아와도 접힌 채로 남는다.
if (persist) writeByKey(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, isSidebar), false);
// 진행단계 패널만 사용자가 자리를 옮긴다(제목 패널은 좌측 도킹 사이드바).
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,
createProjectNameTag(),
);
root.append(titlePanel.root);
setTitleOpen = titlePanel.setOpen;
}
root.append(progressPanel.root);
return {
root,
setTitleOpen,
setProgressOpen: progressPanel.setOpen,
};
}