Files
Aislo/ui_template/ui_template_data_table.ts
T

565 lines
26 KiB
TypeScript

/* =============================================================================
* ui_template_data_table.ts
* 많은 자료용 읽기 표(B08 · B09 공용) — 묶음 머리 여러 줄 · 머리 · 앞 열 고정 스크롤 ·
* 숫자 오른쪽 맞춤 · 천 단위 쉼표 · 열마다 소수 자리 · 빈 값 「-」 · 합계 줄.
* 줄 1,000개도 빠르게 — HTML 글로 통째로 넣고(칸마다 요소 만들지 않음) 보이는 줄 근처만 그림.
*
* const t = createDataTable({ columns, rows, frozen: 1, total });
* host.append(t.root); t.setRows(rows2, total2);
* 열 = { key: "cut_soil_area", label: "단면적", group: ["절토", "토사"], kind: "number", decimals: 2 }
* 고칠 칸(PLAN 121) = editable(줄, 열) 참인 칸만 글 상자 · 바꾸면 onEdit(줄, 열, 글) — 다시 그려도 커서 자리 지킴
* 칸 누름(PLAN 144) = onCellClick(줄, 열) · 줄로 이동 = t.focusRow(줄)(스크롤 + 강조)
* 줄 꼴(PLAN 155-2) = rowClass(줄) → 줄 class(예 is-title 굵게) · 안 주면 글 그대로
* 창 끝까지(PLAN 199) = fillViewport → 표 칸 최대 높이를 창 아래 끝까지(줄이 늘어도 페이지 스크롤 0)
* 레벨(PLAN 201-1 · 보기 전용) = level { key, of(줄) } → 그 열 왼쪽 맞춤 + 레벨마다 1em 들여쓰기(글 상자도)
* 글꼴(PLAN 228-1) = 칸 class is-lv1~5 · 묶음(group · 안 주면 바로 아래 줄이 더 깊은 줄) = is-node 굵게
* 크기 Lv1 16 · Lv2 15 · Lv3 14 · Lv4 13 · Lv5 12(px) · 항목 줄은 14 보다 크지 않게
* 나무(PLAN 235-1 · tree) = 접히지 않는 나무 안내선(├ is-tee · └ is-end · │ is-bar) + 폴더 · 파일 아이콘(공용 나무 꼴)
* 더블클릭 입력(PLAN 235-1 · editOnDouble) = 고칠 칸도 글로 그림 · 더블클릭 · t.edit(줄) = 그 칸만 글 상자 ·
* Enter(조합 끝난 뒤) · 초점 떠남 = 입력 끝(onEdit) — 한 번 누름 = 줄 고름 · 끌기
* 고칠 수 있는 칸 = 글일 때도 칸에 입력 칸 테두리 class `is-can-edit`(PLAN 258-2 · 옅은 outline) ·
* 칸 누른 뒤 Enter · F2 = 그 칸 입력 시작(PLAN 243-1 · keyEdit false = 표 밖이 맡음)
* 같은 값 세로 병합(PLAN 201-1) = mergeSame [열] → 위아래로 같은 값이 이어지면 한 칸(rowSpan) · 본문만
* 앞 병합 열이 갈리면 뒤 병합 열도 갈림 · 빈 값 · 고칠 칸은 안 합침 · 칸 → 열 = `cellColumn`(병합 표는 data-c)
* ========================================================================== */
import "./ui_template_data_table.css";
export interface DataColumn {
/** 줄 객체에서 값을 꺼낼 키 */
key: string;
/** 맨 아래 머리 글 */
label: string;
/** 묶음 머리(위 → 아래) — 이웃 열과 앞부분이 같으면 한 칸으로 합침 */
group?: string[];
/** 기본 글 · 숫자면 오른쪽 맞춤 · 천 단위 쉼표 */
kind?: "number" | "text";
/** 숫자 소수 자리(기본 0) */
decimals?: number;
/** 끝 0 지움(decimals 는 최대 자리) — 사용자가 넣는 값처럼 자리가 줄마다 다른 열 */
trim?: boolean;
/** 열 폭 px(12px 글 기준 · 기본 숫자 84 · 글 96) — 그릴 때 글 비 SCALE 을 곱함 */
width?: number;
/** 맨 아래 머리 칸 풍선 글(기본 label) */
title?: string;
/** 맨 아래 머리 칸에 더할 class(예 빠진 항목 빨강) */
className?: string;
}
export type DataRow = Record<string, unknown>;
export interface DataTableOptions {
columns: DataColumn[];
rows: DataRow[];
/** 앞에서부터 고정할 열 수(기본 0) */
frozen?: number;
/** 합계 줄(맨 아래 고정) — 없으면 안 그림 */
total?: DataRow | null;
/** 표 이름(화면 읽기용) */
caption?: string;
/** 빈 값 글(기본 「-」 · 사용자가 채울 칸은 "") */
empty?: string;
/** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */
onRowClick?: (index: number) => void;
/** 칸 누름(본문 줄만) — 주면 줄에 `data-i`·`is-clickable` 이 붙음 · 줄 누름보다 먼저 부름 */
onCellClick?: (index: number, key: string) => void;
/** 강조할 줄(`focusRow` 가 정함) */
focus?: number | null;
/** 고칠 칸인지(본문 줄만 · 합계 제외) — 참이면 칸이 글 상자 */
editable?: (index: number, key: string) => boolean;
/** 고친 칸 글(바꾼 뒤 · 커서가 옮겨 간 다음에 부름) */
onEdit?: (index: number, key: string, text: string) => void;
/** 줄 꼴 class(본문 줄만 · 예 `is-title` = 굵게) — 빈 값이면 안 붙임 */
rowClass?: (index: number) => string | null | undefined;
/** 칸 꼴 class(본문 줄 · 글 상자 아닌 칸만 · 예 `is-picked` = 고른 칸 강조) — 빈 값이면 안 붙임 */
cellClass?: (index: number, key: string) => string | null | undefined;
/** 참이면 표 칸 최대 높이 = 창 아래 끝까지(창 크기 바뀌면 다시 셈) — 안 주면 CSS 고정값 */
fillViewport?: boolean;
/** 레벨(보기 전용) — key 열을 레벨마다 1em 들여씀 · of = 줄 레벨(1 = 맨 위 · 빈 값 = 1) ·
* group = 묶음 줄인지(안 주면 바로 아래 줄 레벨이 더 큰 줄) — 레벨 · 묶음 따라 글꼴(228-1) */
level?: {
key: string;
of: (index: number) => number | null | undefined;
group?: (index: number) => boolean;
/** 접히지 않는 나무 안내선 · 폴더(묶음) · 파일 아이콘(235-1) */
tree?: boolean;
};
/** 참이면 고칠 칸 = 글 · 더블클릭 · edit() 때만 글 상자(235-1) */
editOnDouble?: boolean;
/** 칸 누른 뒤 표에서 Enter · F2 = 그 칸 입력 시작(editOnDouble · 기본 참 · 거짓 = 표 밖이 키를 맡음) */
keyEdit?: boolean;
/** 지금 글 상자인 칸(editOnDouble · 표가 정함) */
editing?: { index: number; key: string } | null;
/** 같은 값 세로 병합할 열 키(표마다 지정) — 위아래로 같은 글이 이어지면 rowSpan 한 칸 */
mergeSame?: string[];
}
export interface DataTable {
root: HTMLElement;
setRows: (rows: DataRow[], total?: DataRow | null) => void;
/** 그 줄로 스크롤하고 강조(mark = false → 스크롤만 · 강조 풂) */
focusRow: (index: number, mark?: boolean) => void;
/** 그 줄 칸 입력 시작(editOnDouble) — key 없거나 못 고치면 마지막 누른 열 · 첫 고칠 칸 · 못 하면 false */
edit: (index: number, key?: string) => boolean;
}
/** 나무 아이콘 — 폴더 · 항목(글꼴색을 따름) · 공용 나무(`ui_template_tree` 기본 줄)와 같은 꼴 */
export const TREE_ICON = {
folder:
'<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M1.5 3.5h4l1.5 1.5h7.5v8h-13z" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
leaf: '<svg viewBox="0 0 16 16" width="14" height="14" aria-hidden="true"><path d="M3.5 1.5h6l3 3v10h-9z M9.5 1.5v3h3" fill="none" stroke="currentColor" stroke-linejoin="round"/></svg>',
};
const EMPTY = "-";
const ESC: Record<string, string> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" };
const esc = (s: string): string => s.replace(/[&<>"]/g, (c) => ESC[c]);
/** 글 크기 비(14px ÷ 12px) — 페이지가 준 열 폭(12px 글 기준)을 같은 비율로 넓힘 */
export const SCALE = 14 / 12;
function colWidth(c: DataColumn): number {
return Math.round((c.width ?? (c.kind === "number" ? 84 : 96)) * SCALE);
}
/** 값 한 칸 → [보일 글, 빈 값인지] */
export function formatCell(
v: unknown,
fmt: Intl.NumberFormat | null,
empty = EMPTY,
): [string, boolean] {
if (v === null || v === undefined || v === "") return [empty, true];
if (fmt && typeof v === "number")
return Number.isFinite(v) ? [fmt.format(v), false] : [empty, true];
return [String(v), false];
}
function headHtml(cols: DataColumn[], left: (number | null)[]): string {
const depth = Math.max(0, ...cols.map((c) => c.group?.length ?? 0));
const same = (a: DataColumn, b: DataColumn, n: number) =>
(b.group?.length ?? 0) > n &&
a.group!.slice(0, n + 1).join("\u0000") === b.group!.slice(0, n + 1).join("\u0000");
const stick = (from: number, to: number, extra = "") => {
const frz = left[to] !== null;
const cls = [frz && "is-frozen", frz && left[to + 1] == null && "is-edge", extra]
.filter(Boolean)
.join(" ");
return (cls ? ` class="${esc(cls)}"` : "") + (frz ? ` style="left:${left[from]}px"` : "");
};
const rows: string[] = [];
for (let lv = 0; lv <= depth; lv++) {
let tr = "";
for (let j = 0; j < cols.length; j++) {
const c = cols[j];
const g = c.group?.length ?? 0;
if (g > lv) {
let k = j;
while (k + 1 < cols.length && same(c, cols[k + 1], lv)) k++;
tr += `<th scope="colgroup" colspan="${k - j + 1}"${stick(j, k)}>${esc(c.group![lv])}</th>`;
j = k;
} else if (g === lv) {
const num = c.kind === "number" ? ' data-num=""' : "";
const span = depth - lv + 1;
const tip = esc(c.title ?? c.label);
tr += `<th scope="col" rowspan="${span}"${num}${stick(j, j, c.className)} title="${tip}">${esc(c.label)}</th>`;
}
}
rows.push(`<tr>${tr}</tr>`);
}
return rows.join("");
}
function rowHtml(
cols: DataColumn[],
fmts: (Intl.NumberFormat | null)[],
left: (number | null)[],
r: DataRow,
alt = false,
blank = EMPTY,
index: number | null = null,
edit: number | null = null,
editable?: (index: number, key: string) => boolean,
focus = false,
extra: string | null | undefined = null,
cellClass?: (key: string) => string | null | undefined,
more: RowMore = {},
): string {
let tr = "";
const { lv, spans, can } = more;
for (let j = 0; j < cols.length; j++) {
const key = cols[j].key;
const span = spans?.[j] ?? 1;
if (!span) continue; // 위 칸에 합쳐짐
const lvc =
lv?.key === key
? ` ui-data-table__lv is-lv${Math.min(5, lv.n + 1)}${lv.node ? " is-node" : ""}${lv.tree === undefined ? "" : " has-tree"}`
: "";
// 나무(235-1) = 안내선 + 폴더 · 파일 아이콘 — 글 상자 칸은 안내선만
const guide = lv?.key === key ? (lv.tree ?? "") : "";
const c = spans ? ` data-c="${j}"` : "";
if (edit !== null && editable?.(edit, key)) {
const v = r[key];
const raw = v === null || v === undefined ? "" : String(v);
const tip = esc(cols[j].title ?? cols[j].label);
tr += `<td class="is-edit${lvc}" data-num=""${c}>${guide}<input class="ui-data-table__input" data-i="${edit}" data-k="${esc(key)}" value="${esc(raw)}" inputmode="decimal" aria-label="${tip}"></td>`;
continue;
}
const [txt, empty] = formatCell(r[key], fmts[j], blank);
const frz = left[j] !== null;
const cls = [
frz && "is-frozen",
frz && left[j + 1] == null && "is-edge",
empty && "is-empty",
cellClass?.(key),
can?.(key) && "is-can-edit",
lvc.trim(),
span > 1 && "is-merged",
]
.filter(Boolean)
.join(" ");
const attrs =
(cls ? ` class="${cls}"` : "") +
(frz ? ` style="left:${left[j]}px"` : "") +
(fmts[j] ? ' data-num=""' : "") +
(span > 1 ? ` rowspan="${span}"` : "") +
c;
const icon =
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 cls = [alt && "is-alt", index !== null && "is-clickable", focus && "is-focus", extra]
.filter(Boolean)
.join(" ");
const data =
(index !== null ? ` data-i="${index}"` : "") + (lv ? ` style="--dt-lv:${lv.n}"` : "");
return cls || data
? `<tr${cls ? ` class="${esc(cls)}"` : ""}${data}>${tr}</tr>`
: `<tr>${tr}</tr>`;
}
/** 줄 곁값 — lv = 레벨 열 · 들여쓸 칸 수 · 묶음 줄인지 · 나무 안내선 HTML(나무 아니면 없음) ·
* spans = 열마다 rowSpan(0 = 위 칸에 합쳐짐) */
interface RowMore {
lv?: { key: string; n: number; node: boolean; tree?: string };
spans?: number[];
/** 고칠 수 있는 칸인지(editOnDouble · 글로 그린 칸에 `is-can-edit` 표시 · 258-2) */
can?: (key: string) => boolean;
}
/** 칸 → 열 번호 — 병합 표는 칸이 빠져 cellIndex 가 밀리므로 data-c */
export function cellColumn(cell: HTMLTableCellElement): number {
return cell.dataset.c !== undefined ? Number(cell.dataset.c) : cell.cellIndex;
}
/** 병합 구간(from ~ to 줄) — [줄][열] rowSpan · 0 = 위 칸에 합쳐짐 · 병합 안 하는 열 = 1 */
export function mergeSpans(o: DataTableOptions, from: number, to: number): number[][] {
const cols = o.columns;
const keys = new Set(o.mergeSame ?? []);
const out = Array.from({ length: to - from }, () => cols.map(() => 1));
// 이음 = 이 열까지 앞 병합 열이 모두 위 줄과 같은 글(빈 값 · 고칠 칸 빼고)
const shown = (i: number, j: number): string | null => {
const key = cols[j].key;
if (o.editable?.(i, key)) return null;
const [txt] = formatCell(o.rows[i][key], null, "");
return txt.trim() ? txt : null; // 빈 값 · 빈칸 글(" ")은 안 합침
};
for (let i = from + 1; i < to; i++) {
let joined = true;
for (let j = 0; j < cols.length; j++) {
if (!keys.has(cols[j].key)) continue;
const v = shown(i, j);
joined = joined && v !== null && v === shown(i - 1, j);
if (!joined) continue;
let top = i - 1;
while (!out[top - from][j]) top--;
out[top - from][j]++;
out[i - from][j] = 0;
}
}
return out;
}
/** 나무 안내선(235-1) — 줄마다 칸(한 단 = 들여쓰기 한 칸 · 레벨 2 부터) span HTML.
* 자기 칸 = 아래에 같은 레벨 형제가 더 있으면 ├(is-tee) · 끝이면 └(is-end) · 윗대 칸 = 그 윗대 형제가 아래에 더 있으면 │(is-bar) */
export function treeGuides(levels: number[]): string[] {
const out: string[] = new Array(levels.length);
const more: boolean[] = []; // 레벨 d 형제가 아래에 더 있음(더 얕은 줄 전까지)
const span = (cls: string, slot: number) =>
`<span class="ui-data-table__guide ${cls}" style="--g:${slot}"></span>`;
for (let i = levels.length - 1; i >= 0; i--) {
const lv = Math.max(1, levels[i]);
let html = "";
for (let d = 2; d < lv; d++) if (more[d]) html += span("is-bar", d - 2);
if (lv >= 2) html += span(more[lv] ? "is-tee" : "is-end", lv - 2);
out[i] = html;
more[lv] = true;
more.length = lv + 1; // 더 깊은 레벨 = 이 줄에서 끊김
}
return out;
}
/** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */
export interface RowWindow {
from: number;
to: number;
rowH: number;
}
const pad = (h: number, n: number) =>
h > 0 ? `<tr class="ui-data-table__pad" style="height:${h}px"><td colspan="${n}"></td></tr>` : "";
/** 표 전체 HTML 글 — DOM 없이 만듦(시험 · 서버 그리기도 같은 글) · win 없으면 모든 줄 */
export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): string {
const cols = o.columns;
const frozen = Math.min(o.frozen ?? 0, cols.length);
const left: (number | null)[] = [];
let x = 0;
cols.forEach((c, i) => {
left.push(i < frozen ? x : null);
x += colWidth(c);
});
const fmts = cols.map((c) =>
c.kind === "number"
? new Intl.NumberFormat("ko-KR", {
minimumFractionDigits: c.trim ? 0 : (c.decimals ?? 0),
maximumFractionDigits: c.decimals ?? 0,
})
: null,
);
const colgroup = cols.map((c) => `<col style="width:${colWidth(c)}px">`).join("");
const n = o.rows.length;
const from = win ? Math.max(0, Math.min(win.from, n)) : 0;
const to = win ? Math.max(from, Math.min(win.to, n)) : n;
let body = win ? pad(from * win.rowH, cols.length) : "";
const blank = o.empty ?? EMPTY;
const pick = o.onRowClick !== undefined || o.onCellClick !== undefined;
const spans = o.mergeSame?.length ? mergeSpans(o, from, to) : null;
const tree = o.level?.tree ? treeGuides(o.rows.map((_r, i) => o.level!.of(i) ?? 1)) : null;
// 더블클릭 입력(235-1) — 글 상자 = 지금 고치는 칸만
const inputAt = o.editOnDouble
? (i: number, key: string) =>
o.editing?.index === i && o.editing.key === key && !!o.editable?.(i, key)
: o.editable;
for (let i = from; i < to; i++) {
const lvN = o.level ? Math.max(0, (o.level.of(i) ?? 1) - 1) : 0;
const node = o.level ? isNode(o.level, i, n) : false;
body += rowHtml(
cols,
fmts,
left,
o.rows[i],
i % 2 === 1,
blank,
pick ? i : null,
i,
inputAt,
o.focus === i,
o.rowClass?.(i),
o.cellClass ? (key) => o.cellClass?.(i, key) : undefined,
{
...(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) } : {}),
},
);
}
if (win) body += pad((n - to) * win.rowH, cols.length);
const foot = o.total ? `<tfoot>${rowHtml(cols, fmts, left, o.total, false, blank)}</tfoot>` : "";
const cap = o.caption ? `<caption>${esc(o.caption)}</caption>` : "";
return (
`<table class="ui-data-table__table" style="--dt-w:${x}px">${cap}<colgroup>${colgroup}</colgroup>` +
`<thead>${headHtml(cols, left)}</thead><tbody>${body}</tbody>${foot}</table>`
);
}
/** 묶음 줄(228-1) — group 이 주면 그 값 · 안 주면 바로 아래 줄 레벨이 더 큼 */
function isNode(lv: NonNullable<DataTableOptions["level"]>, i: number, n: number): boolean {
if (lv.group) return lv.group(i);
return i + 1 < n && (lv.of(i + 1) ?? 1) > (lv.of(i) ?? 1);
}
/** 화면 위아래로 더 그려 둘 줄 수 · 구간은 이 단위로 끊어 스크롤마다 다시 그리지 않음 */
const OVER = 40;
/* ── 창 아래 끝까지(PLAN 199) — 칸 최대 높이 = 창 높이 − 칸 위쪽 자리 − 아래 자리 ────────────
* 아래 자리 = 칸 뒤 형제 요소 높이 · 바깥 여백 + 윗대 요소의 아래 안쪽 여백 · 테두리 · 바깥 여백.
* 줄이 적으면 내용만큼 · 창 크기 · 위아래 요소가 바뀌면 다시 셈 · 칸이 문서에서 떨어지면 스스로 멈춤.
* ------------------------------------------------------------------------------------------ */
/** 칸이 이보다 작아지지 않게(아주 작은 창에서 머리만 남는 것 방지) */
const MIN_HEIGHT = 120;
/** 창 기준 최대 높이(px) — `top` = 문서 맨 위에서 칸 위까지 · `below` = 칸 아래로 남겨야 할 자리 */
export function fillHeight(viewport: number, top: number, below: number): number {
return Math.max(MIN_HEIGHT, Math.floor(viewport - top - below));
}
const px = (v: string): number => parseFloat(v) || 0;
/** 칸 아래로 문서 끝까지 차지하는 자리 */
export function spaceBelow(el: HTMLElement): number {
let sum = 0;
for (let node: HTMLElement = el; node.parentElement; node = node.parentElement) {
const parent = node.parentElement;
const own = getComputedStyle(node);
const around = getComputedStyle(parent);
const gap = px(around.rowGap);
for (let s = node.nextElementSibling; s; s = s.nextElementSibling) {
const h = s.getBoundingClientRect().height;
const m = getComputedStyle(s); // 203 형제 위아래 바깥 여백도(B09 안내 글 margin-top)
if (h) sum += h + gap + px(m.marginTop) + px(m.marginBottom);
}
sum += px(own.marginBottom) + px(around.paddingBottom) + px(around.borderBottomWidth);
}
return sum;
}
export function fillViewport(el: HTMLElement, onFit?: () => void): () => void {
let seen = false; // 한 번이라도 문서에 붙었는지 — 붙었다가 떨어지면 멈춤
let observer: ResizeObserver | null = null;
const fit = (): void => {
if (!el.isConnected) {
if (seen) stop();
return;
}
seen = true;
const top = el.getBoundingClientRect().top + window.scrollY;
el.style.maxHeight = `${fillHeight(window.innerHeight, top, spaceBelow(el))}px`;
onFit?.();
};
const stop = (): void => {
window.removeEventListener("resize", fit);
observer?.disconnect();
};
window.addEventListener("resize", fit);
// 칸이 붙을 때 · 줄이 늘 때 · 위아래 요소가 바뀔 때(문서 높이) 다시 셈
observer = new ResizeObserver(fit);
observer.observe(el);
observer.observe(document.body);
return stop;
}
export function createDataTable(opts: DataTableOptions): DataTable {
const root = document.createElement("div");
root.className = "ui-data-table";
root.tabIndex = 0; // 키보드로 표 안 스크롤
const state = { ...opts };
let win: RowWindow = { from: -1, to: -1, rowH: 28 }; // --dt-row-h · 그린 뒤 재서 고침
// 보이는 줄 근처만 그림 — 줄 1,000개를 다 배치하면 수백 ms
const draw = (force: boolean) => {
const n = state.rows.length;
const view = Math.ceil((root.clientHeight || 800) / win.rowH);
const from = Math.max(0, Math.floor(root.scrollTop / win.rowH / OVER) * OVER - OVER);
const to = Math.min(n, from + view + 3 * OVER);
if (!force && from === win.from && to === win.to) return;
win = { ...win, from, to };
// 고치던 칸 — 다시 그린 뒤 같은 칸에 커서를 돌려 놓음(Tab 으로 옮겨 간 칸 포함)
const active = document.activeElement;
const focus =
active instanceof HTMLInputElement && root.contains(active)
? `input[data-i="${active.dataset.i}"][data-k="${CSS.escape(active.dataset.k ?? "")}"]`
: null;
root.innerHTML = renderDataTableHtml(state, win);
if (focus) root.querySelector<HTMLInputElement>(focus)?.focus();
// 줄 높이는 그린 줄을 재서 맞춤(글꼴 · 토큰이 바뀌어도)
const tr = root.querySelector<HTMLElement>("tbody tr:not(.ui-data-table__pad)");
if (tr && tr.offsetHeight && tr.offsetHeight !== win.rowH) {
win = { ...win, rowH: tr.offsetHeight };
draw(true);
}
};
root.addEventListener("scroll", () => draw(false), { passive: true });
// 바꾼 칸 — 커서가 다음 칸으로 옮겨 간 뒤 알림(그 사이 다시 그리면 Tab 이 갈 곳을 잃음)
root.addEventListener("change", (event) => {
const input = event.target as HTMLInputElement;
if (!input.dataset?.k) return;
const index = Number(input.dataset.i);
const key = input.dataset.k;
setTimeout(() => state.onEdit?.(index, key, input.value));
});
/** 누른 칸 [줄, 열 키] — 본문 줄 밖 = null */
const cellAt = (target: EventTarget | null): [number, string] | null => {
const row = (target as Element).closest<HTMLElement>("tr[data-i]");
if (!row || !root.contains(row)) return null;
const cell = (target as Element).closest<HTMLTableCellElement>("td");
const column = cell ? state.columns[cellColumn(cell)] : undefined;
return [Number(row.dataset.i), column?.key ?? ""];
};
let lastKey = "";
let lastRow: number | null = null; // 마지막 누른 줄 — Enter · F2 입력 시작 자리
root.addEventListener("click", (event) => {
const at = cellAt(event.target);
if (!at) return;
lastKey = at[1];
lastRow = at[0];
if (at[1]) state.onCellClick?.(at[0], at[1]);
state.onRowClick?.(at[0]);
});
const edit = (index: number, key?: string): boolean => {
if (!state.editOnDouble || !state.editable) return false;
const can = state.columns.map((c) => c.key).filter((k) => state.editable!(index, k));
const k = [key, lastKey, can[0]].find((x) => x !== undefined && can.includes(x));
if (k === undefined) return false;
state.editing = { index, key: k };
draw(true);
const input = root.querySelector<HTMLInputElement>(
`input[data-i="${index}"][data-k="${CSS.escape(k)}"]`,
);
input?.focus();
input?.select();
return true;
};
if (opts.editOnDouble) {
root.addEventListener("dblclick", (event) => {
const at = cellAt(event.target);
if (at && state.editable?.(at[0], at[1])) edit(at[0], at[1]);
});
// Enter = 입력 끝(한글 조합 중 Enter 는 조합 끝) — 표에 초점 · 키가 문서까지 가지 않게(다시 입력 시작 방지)
// 표에 초점이 있을 때 Enter · F2 = 누른 칸 입력 시작(keyEdit)
root.addEventListener("keydown", (event) => {
if ((event.key !== "Enter" && event.key !== "F2") || event.isComposing) return;
if (!(event.target instanceof HTMLInputElement)) {
if (opts.keyEdit === false || event.target !== root || lastRow === null) return;
if (state.editable?.(lastRow, lastKey)) {
event.preventDefault();
edit(lastRow, lastKey);
}
return;
}
if (event.key !== "Enter") return;
event.preventDefault();
event.stopPropagation();
root.focus();
});
// 초점 떠남 = 입력 끝 — 다시 그린 같은 칸으로 초점이 돌아왔으면 그대로
root.addEventListener("focusout", (event) => {
const input = event.target as HTMLInputElement;
if (!state.editing || !input.dataset?.k) return;
setTimeout(() => {
const now = document.activeElement as HTMLElement | null;
const back = now && root.contains(now) && now.dataset.k === input.dataset.k;
if (back && now.dataset.i === input.dataset.i) return;
state.editing = null;
draw(true);
});
});
}
draw(true);
if (opts.fillViewport) fillViewport(root, () => draw(false));
return {
root,
setRows(rows, total) {
state.rows = rows;
if (total !== undefined) state.total = total;
draw(true);
},
focusRow(index, mark = true) {
state.focus = mark ? index : null;
root.scrollTop = Math.max(0, index * win.rowH - root.clientHeight / 3);
draw(true);
},
edit,
};
}