Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-11-02 13:24:26 +09:00
7 changed files with 92 additions and 2 deletions
@@ -252,6 +252,14 @@
min-height: 0;
}
/* 좁은 폭(730-2) — 오른쪽 목록 22rem 이 시트 칸을 폭 0 으로 짓누름 → 위(시트) · 아래(목록) 쌓기 */
@media (max-width: 900px) {
.m02-hopyo-sheet {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 3fr) minmax(0, 2fr);
}
}
.m02-hopyo-sheet__grid {
display: flex;
flex-direction: column;
@@ -5,7 +5,7 @@
* · 옵션 값 = 규격 칸 · 「빈 값 n」 = 비고 칸 붉게(입력 빈 로직 줄)
* · 수량 칸 = 확인용 임시 입력(723-1 · 휘발 · 문서 · 저장 · dirty 안 건드림) → 그 줄 금액 · 합계 미리 보기
* (짝 셈 `Labor_Calc.settle` · 서버 안 부름) · 문서 안 줄 수량은 늘 null(쓰는 쪽 알약으로 이음)
* · 머리 단추 = [로직 추가] [자재 추가] · 줄 우클릭 = 위로 · 아래로 · 별칭 · 삭제
* · 머리 단추 = [로직 추가] [자재 추가] · 줄 우클릭 · 줄 끝 ⋯ = 위로 · 아래로 · 별칭 · 삭제
* · 줄 누름 = 줄 모달(`Labor_RowModal` — 옵션 · 메모 · 켜쌓기율 · 로직 자재 바꾸기 · 세 칸 · 단가산출) ·
* 사전조합 참조 줄 = 서고 원본 모달(`Labor_RefEdit`) · 사용자 자재 줄 = 자재 모달(세 칸)
* · 금액 = 서버 calc(최신 응답만 · 저장 안 함) · [저장]은 UI_Main(서버가 수량 걷고 다시 셈)
@@ -155,6 +155,13 @@ export function mountLaborTable(
const n = at(i);
if (n !== null) openAt(n);
},
// 줄 끝 ⋯(731-1) — 우클릭 · 길게 누름이 없어도 같은 메뉴(위로 · 아래로 · 별칭 · 삭제)
rowMenu: edit
? {
show: (i) => at(i) !== null,
open: (i, ev) => openTreeMenu(ev, menuAt(at(i) ?? 0)),
}
: undefined,
editable: (i, key) => {
const n = at(i);
if (key !== "qty" || n === null) return false;
+18
View File
@@ -96,6 +96,24 @@ console.log(
total: { x: 3 },
rowClass: (i) => (i === 0 ? "is-title" : null),
}),
rowMenu: m.renderDataTableHtml({
columns: [
{ key: "a", label: "A" },
{ key: "b", label: "B" },
],
rows: [
{ a: "제목", b: 1 },
{ a: "줄", b: 2 },
{ a: "줄2", b: 3 },
],
onRowClick: () => undefined,
rowMenu: { show: (i) => i > 0, open: () => undefined },
}),
noRowMenu: m.renderDataTableHtml({
columns: [{ key: "a", label: "A" }],
rows: [{ a: "줄" }],
onRowClick: () => undefined,
}),
scale: m.SCALE,
widths: m.renderDataTableHtml({
columns: [
@@ -0,0 +1,15 @@
"""730-2 지킴 — 엑셀 보기 창은 좁은 폭에서 시트(위) · 목록(아래)로 쌓임(오른쪽 22rem 이 시트를 폭 0 으로 짓누르지 않음)."""
import re
from pathlib import Path
CSS = (Path(__file__).parents[2] / "M02_MasterTemplete/M02_MasterTemplete_Hopyo.css").read_text(
encoding="utf-8"
)
def test_sheet_stacks_on_narrow_width():
m = re.search(r"@media \(max-width: (\d+)px\) \{\s*\.m02-hopyo-sheet \{(.*?)\}", CSS, re.S)
assert m and int(m.group(1)) >= 700
assert "grid-template-columns: minmax(0, 1fr)" in m.group(2)
assert "grid-template-rows" in m.group(2)
+12
View File
@@ -122,3 +122,15 @@ def test_155_2_줄_꼴_곁값(r):
assert rc.count("is-title") == 1 # 합계 줄엔 안 붙음
assert "white-space-collapse: preserve-spaces;" in r["css"]
assert "tr.is-title td" in r["css"]
def test_줄_끝_메뉴_단추_731(r):
"""rowMenu — show 참인 줄의 첫 칸에만 ⋯ 단추 · 없으면 단추 없음 · 줄 번호가 data-more 로."""
rows = r["rowMenu"].split("<tbody>")[1].split("</tbody>")[0].split("</tr>")[:3]
assert "data-more" not in rows[0]
for i in (1, 2):
assert rows[i].count("data-more") == 1
assert f'data-more="{i}"' in rows[i]
assert rows[i].split("</td>")[0].count("data-more") == 1 # 첫 칸 안
assert 'class="ui-icon-btn ui-icon-btn--ghost ui-data-table__more"' in rows[1]
assert "data-more" not in r["noRowMenu"]
+14
View File
@@ -330,3 +330,17 @@
:is(.ui-data-table__icon, .ui-data-table__lamp)[hidden] {
display: none !important;
}
/* 줄 끝 ⋯(731-1) — 첫 칸 오른쪽 가운데에 얹음(줄 높이를 안 밀음) · 글은 단추 자리만큼 비워 줄임표 */
.ui-data-table__table td:has(> .ui-data-table__more) {
padding-right: calc(var(--ui-size-h, 28px) + var(--spacing-4));
}
.ui-data-table__table td:has(> .ui-data-table__more):not(.is-frozen) {
position: relative; /* 고정 칸은 이미 sticky(자리 기준) */
}
.ui-data-table__table td > .ui-data-table__more {
position: absolute;
top: 50%;
right: var(--spacing-4);
transform: translateY(-50%);
}
+17 -1
View File
@@ -21,6 +21,8 @@
* 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(PLAN 243-1 · keyEdit false = 표 밖이 맡음)
* 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만
* 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c)
* 줄 끝 메뉴(731-1) = rowMenu { show(줄), open(줄, 누름) } → show 참인 줄의 첫 칸 오른쪽에 ⋯ 단추(우클릭 · 길게 누름이 없는 손가락 · 마우스도 보이는 길) ·
* 누르면 줄 누름 없이 open 만 부름(메뉴는 부르는 쪽이 엶) · 단추 꼴 = 아이콘 단추(28 · ghost) 그대로(DOM 없는 시험도 같은 글)
* 칸 램프(557-2) = cellClass 가 `ui-data-table__lamp` → 칸 글 앞 점 8 · 노랑 = 없음 · `is-linked` = 초록 ✓(색만으로 안 가름) ·
* `ui-data-table__lamp--only` = 글 숨기고 점만 가운데 · 대신하는 옛 변종 = B08 `b08-summary__lamp` · `b08-milestone__lamp` · B09 `b09-bill__lamp`
* ========================================================================== */
@@ -65,6 +67,8 @@ export interface DataTableOptions {
onRowClick?: (index: number) => void;
/** 칸 누름(본문 줄만) — 주면 줄에 `data-i`·`is-clickable` 이 붙음 · 줄 누름보다 먼저 부름 */
onCellClick?: (index: number, key: string) => void;
/** 줄 끝 ⋯ 메뉴(731-1) — show 참인 줄의 첫 칸에 ⋯ 단추 · 누르면 줄 누름 없이 open(줄 · 누름 이벤트) */
rowMenu?: { show: (index: number) => boolean; open: (index: number, event: MouseEvent) => void };
/** 강조할 줄(`focusRow` 가 정함) */
focus?: number | null;
/** 고칠 칸인지(본문 줄만 · 합계 제외) — 참이면 칸이 글 상자 */
@@ -113,6 +117,9 @@ export const TREE_ICON = {
};
const EMPTY = "-";
/** ⋯ 단추(731-1) — 아이콘 단추(`createIconButton` icon "dots" · 28 · ghost)와 같은 글 · 표 글은 DOM 없이 만들어서 글로 둠 */
const MORE_BUTTON = (i: number): string =>
`<button type="button" class="ui-icon-btn ui-icon-btn--ghost ui-data-table__more" data-size="28" data-more="${i}" title="더 보기" aria-label="더 보기"><span class="ui-icon" aria-hidden="true"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M12 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/><path d="M19 12m-1 0a1 1 0 1 0 2 0a1 1 0 1 0 -2 0"/></svg></span></button>`;
const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]);
@@ -228,7 +235,8 @@ function rowHtml(
lv?.key === key && lv.tree !== undefined
? `<span class="ui-data-table__icon">${lv.node ? TREE_ICON.folder : TREE_ICON.leaf}</span>`
: "";
tr += `<td${attrs}>${guide}${icon}${esc(txt)}</td>`;
const menu = j === 0 && more.menu && index !== null ? MORE_BUTTON(index) : "";
tr += `<td${attrs}>${menu}${guide}${icon}${esc(txt)}</td>`;
}
const cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra]
.filter(Boolean)
@@ -247,6 +255,8 @@ interface RowMore {
spans?: number[];
/** 고칠 수 있는 칸인지(editOnDouble · 글로 그린 칸에 `is-can-edit` 표시 · 258-2) */
can?: (key: string) => boolean;
/** 줄 끝 ⋯ 단추(731-1) — 첫 칸 앞에(오른쪽 맞춤 · 글 앞에 놓아야 한 줄에 앉음) */
menu?: boolean;
}
/** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */
@@ -363,6 +373,7 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin
...(o.level ? { lv: { key: o.level.key, n: lvN, node, tree: tree?.[i] } } : {}),
...(spans ? { spans: spans[i - from] } : {}),
...(o.editOnDouble && o.editable ? { can: (key: string) => o.editable!(i, key) } : {}),
...(o.rowMenu?.show(i) ? { menu: true } : {}),
},
);
}
@@ -492,6 +503,11 @@ export function createDataTable(opts: DataTableOptions): DataTable {
let lastKey = "";
let lastRow: number | null = null; // 마지막 누른 줄 — Enter · F2 입력 시작 자리
root.addEventListener("click", (event) => {
const more = (event.target as Element).closest<HTMLElement>("[data-more]");
if (more && root.contains(more)) {
state.rowMenu?.open(Number(more.dataset.more), event); // 줄 누름 없이 메뉴만(731-1)
return;
}
const at = cellAt(event.target);
if (!at) return;
lastKey = at[1];