dataTable/dist/datatable.js

723 lines
33 KiB
JavaScript
Raw Permalink Normal View History

import { Component, NewState, RefreshState, Util } from "@web/state";
import { VirtualScroll, State } from "@web/base";
const createPerfMonitor = () => {
let enabled = !!window.__DT_PERF_MODE__;
const stats = { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 };
if (enabled && !window.__statePerformanceTelemetry) {
window.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 };
}
return {
get stats() {
return stats;
},
enable: () => {
enabled = true;
},
disable: () => {
enabled = false;
},
onScroll: () => {
if (enabled) stats.scrollCount++;
},
startFrame: () => {
var _a, _b, _c;
if (!enabled) return null;
return {
start: performance.now(),
scan: ((_a = window.__statePerformanceTelemetry) == null ? void 0 : _a.scanCount) || 0,
move: ((_b = window.__statePerformanceTelemetry) == null ? void 0 : _b.moveCount) || 0,
reuse: ((_c = window.__statePerformanceTelemetry) == null ? void 0 : _c.reuseCount) || 0
};
},
endFrame: (startData, renderedCount) => {
if (!enabled || !startData) return;
stats.refreshCount++;
stats.totalNodes += renderedCount;
const elapsed = performance.now() - startData.start;
stats.refreshTime += elapsed;
const stPerf = window.__statePerformanceTelemetry;
if (stPerf) {
const scans = stPerf.scanCount - startData.scan;
const moves = stPerf.moveCount - startData.move;
const reuses = stPerf.reuseCount - startData.reuse;
if (scans > 0 || elapsed > 2) {
console.log(`[DataTable Frame] Time: ${elapsed.toFixed(2)}ms, Scans: ${scans}, Moves: ${moves}, Reuses: ${reuses}, Rows: ${renderedCount}`);
}
}
}
};
};
const createScrollManager = (container, state, onRenderedListChange) => {
const vs = VirtualScroll({ itemHeight: 40 });
let scrollEl = null;
const refresh = (isLayoutChange = false) => {
2026-05-17 17:03:21 +08:00
if (!scrollEl) return;
const res = vs.calc(scrollEl, state.list);
if (res) {
if (!isLayoutChange && state.prevHeight === res.prevHeight && state.postHeight === res.postHeight && state._listStartIndex === res.listStartIndex && state._renderedList.length === res.renderedList.length) {
return;
}
Object.assign(state, {
prevHeight: res.prevHeight,
postHeight: res.postHeight,
_listStartIndex: res.listStartIndex,
_renderedList: res.renderedList
});
onRenderedListChange == null ? void 0 : onRenderedListChange(res.renderedList.length, isLayoutChange);
}
2026-05-17 17:03:21 +08:00
};
return {
init: () => {
scrollEl = container.querySelector(".dt-main");
},
reset: (list) => {
state._listStartIndex = 0;
vs.reset(list, scrollEl || container);
if (state.list === list) {
vs.init(list, () => refresh(true));
}
},
refresh,
onScroll: () => refresh(false)
};
};
const createSelectionManager = (container, state) => {
let activeBounds = null;
let startCell = null;
let multiSelections = [];
const isCellSelected = (r, c) => {
if (activeBounds && r >= activeBounds.minRow && r <= activeBounds.maxRow && c >= activeBounds.minCol && c <= activeBounds.maxCol) return true;
return multiSelections.some((s) => r >= s.minRow && r <= s.maxRow && c >= s.minCol && c <= s.maxCol);
};
let lastHadSelection = false;
const applySelectionUI = () => {
if (window.__DT_FEATURES__ && !window.__DT_FEATURES__.selection) return;
let boundMinRow = Infinity, boundMaxRow = -Infinity;
if (activeBounds) {
boundMinRow = Math.min(boundMinRow, activeBounds.minRow);
boundMaxRow = Math.max(boundMaxRow, activeBounds.maxRow);
}
multiSelections.forEach((s) => {
boundMinRow = Math.min(boundMinRow, s.minRow);
boundMaxRow = Math.max(boundMaxRow, s.maxRow);
});
const hasSelection = boundMinRow !== Infinity;
if (!hasSelection && !lastHadSelection) return;
lastHadSelection = hasSelection;
const body = container.querySelector(".dt-body");
if (!body) return;
const rowNodes = body.querySelectorAll(".dt-body-row");
rowNodes.forEach((rowNode) => {
var _a;
const absoluteRow = (((_a = rowNode._ref) == null ? void 0 : _a.rIdx) ?? -1) + state._listStartIndex;
const cells = rowNode.querySelectorAll(".dt-cell");
if (!hasSelection || absoluteRow < boundMinRow || absoluteRow > boundMaxRow) {
cells.forEach((cell) => cell.classList.remove("dt-cell-selected"));
return;
}
cells.forEach((cell, cIdx) => {
if (isCellSelected(absoluteRow, cIdx)) {
cell.classList.add("dt-cell-selected");
} else {
cell.classList.remove("dt-cell-selected");
}
});
});
2026-05-17 17:03:21 +08:00
};
const updateStatus = () => {
let count = 0;
if (activeBounds) count += activeBounds.maxRow - activeBounds.minRow + 1;
multiSelections.forEach((s) => count += s.maxRow - s.minRow + 1);
state.selectedRowCount = count;
};
const clearAllActive = (keepSelection = false) => {
2026-05-17 17:03:21 +08:00
if (!keepSelection) {
activeBounds = null;
startCell = null;
multiSelections = [];
applySelectionUI();
updateStatus();
2026-05-17 17:03:21 +08:00
}
};
const startSelect = (row, col, e) => {
const alreadySelected = isCellSelected(row, col);
const isRange = activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol) || multiSelections.length > 0;
if (e.shiftKey && startCell) {
activeBounds = {
minRow: Math.min(startCell.row, row),
maxRow: Math.max(startCell.row, row),
minCol: Math.min(startCell.col, col),
maxCol: Math.max(startCell.col, col)
};
2026-05-17 17:03:21 +08:00
} else {
if (alreadySelected && !e.ctrlKey && !e.metaKey) {
if (!isRange) container._potentialCancel = { row, col };
} else {
if (!e.ctrlKey && !e.metaKey) {
clearAllActive();
} else if (activeBounds && !alreadySelected) {
multiSelections.push(activeBounds);
2026-05-17 17:03:21 +08:00
}
startCell = { row, col };
activeBounds = { minRow: row, maxRow: row, minCol: col, maxCol: col };
2026-05-17 17:03:21 +08:00
}
state.isSelecting = true;
}
applySelectionUI();
updateStatus();
container.focus();
2026-05-17 17:03:21 +08:00
};
const updateSelect = (row, col) => {
if (state.isSelecting && startCell) {
activeBounds = {
minRow: Math.min(startCell.row, row),
maxRow: Math.max(startCell.row, row),
minCol: Math.min(startCell.col, col),
maxCol: Math.max(startCell.col, col)
};
container._potentialCancel = null;
applySelectionUI();
updateStatus();
}
};
const endSelect = () => {
if (container._potentialCancel) {
const { row, col } = container._potentialCancel;
if (isCellSelected(row, col)) {
clearAllActive();
}
container._potentialCancel = null;
2026-05-17 17:03:21 +08:00
}
state.isSelecting = false;
};
const getSelectionBounds = () => {
if (!activeBounds) return null;
let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow;
let minCol = activeBounds.minCol, maxCol = activeBounds.maxCol;
multiSelections.forEach((s) => {
minRow = Math.min(minRow, s.minRow);
maxRow = Math.max(maxRow, s.maxRow);
minCol = Math.min(minCol, s.minCol);
maxCol = Math.max(maxCol, s.maxCol);
});
return { minRow, maxRow, minCol, maxCol };
};
const copy = async () => {
const bounds = getSelectionBounds();
if (!bounds) return;
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map((row) => {
return state.fields.slice(bounds.minCol, bounds.maxCol + 1).map((f) => {
let val = String(row[f.id] ?? "");
if (val.includes(" ") || val.includes("\n") || val.includes('"')) {
val = '"' + val.replace(/"/g, '""') + '"';
}
return val;
}).join(" ");
}).join("\n");
await navigator.clipboard.writeText(text);
};
const paste = async () => {
try {
const text = await navigator.clipboard.readText();
if (!text) return;
const bounds = getSelectionBounds();
if (!bounds) return;
const rows = text.split(/\r?\n/).filter((line) => line.length > 0).map((line) => {
const cells = [];
let current = "", inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
current += '"';
i++;
} else inQuotes = !inQuotes;
} else if (char === " " && !inQuotes) {
cells.push(current);
current = "";
} else {
current += char;
}
2026-05-17 17:03:21 +08:00
}
cells.push(current);
return cells;
});
const startRow = bounds.minRow;
const startCol = bounds.minCol;
const maxRow = bounds.maxRow;
const maxCol = bounds.maxCol;
const body = container.querySelector(".dt-body");
const rowNodes = body ? Array.from(body.childNodes).filter((n) => {
var _a;
return (_a = n.classList) == null ? void 0 : _a.contains("dt-body-row");
}) : [];
rows.forEach((rowData, rOffset) => {
const rIdx = startRow + rOffset;
if (rIdx > maxRow || rIdx >= state.list.length) return;
const rowItem = state.list[rIdx];
let rowChanged = false;
rowData.forEach((cellData, cOffset) => {
const cIdx = startCol + cOffset;
if (cIdx > maxCol || cIdx >= state.fields.length) return;
const field = state.fields[cIdx];
rowItem[field.id] = cellData;
rowChanged = true;
});
if (rowChanged && container.refreshNode) {
const domNode = rowNodes.find((n) => {
var _a;
return (((_a = n._ref) == null ? void 0 : _a.rIdx) ?? -1) + state._listStartIndex === rIdx;
});
if (domNode) container.refreshNode(domNode);
}
2026-05-17 17:03:21 +08:00
});
} catch (err) {
console.error("Paste Error:", err);
2026-05-17 17:03:21 +08:00
}
};
const deleteSelected = () => {
const bounds = getSelectionBounds();
if (!bounds) return;
const rMin = bounds.minRow, rMax = bounds.maxRow;
const newList = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
state.list = newList;
clearAllActive();
2026-05-17 17:03:21 +08:00
};
return {
applySelectionUI,
clearAllActive,
startSelect,
updateSelect,
endSelect,
deleteSelected,
getSelectionBounds,
copy,
paste
};
};
const MODE_MAP = {
text: ["contains", "equals", "starts", "ends"],
textarea: ["contains", "equals", "starts", "ends"],
number: ["=", ">", "<", "between"],
date: ["=", ">", "<", "between"],
select: ["contains", "equals"],
TagsInput: ["contains", "equals", "starts", "ends"]
};
const MODE_ICONS = {
"contains": "bi-search",
"equals": "bi-distribute-vertical",
"starts": "bi-align-start",
"ends": "bi-align-end",
"=": "bi-calculator",
">": "bi-chevron-right",
"<": "bi-chevron-left",
"between": "bi-arrows-expand"
};
Component.register("DataTable", (container) => {
if (!container.state) container.state = NewState({});
const state = container.state;
Object.assign(state, {
list: [],
fields: [],
_renderedList: [],
prevHeight: 0,
postHeight: 0,
_listStartIndex: 0,
selectedRowCount: 0,
_originalList: [],
sortConfig: { fieldId: null, direction: null },
filterConfig: {},
// fieldId -> { mode, value, value2, selectedValues: [] }
activeFieldId: null,
activeField: null,
activeModes: [],
_columnStats: {},
_internalUpdate: false,
_appliedHash: "",
_fieldsDirty: false,
_masterCellNodes: null
});
const perf = createPerfMonitor();
state.perf = perf.stats;
const selection = createSelectionManager(container, state);
const scroll = createScrollManager(container, state, () => selection.applySelectionUI());
const menuNode = container.querySelector(".dt-column-menu");
if (menuNode) menuNode._thisObj = container;
container.onColumnResizing = (field, e) => container.style.setProperty(`--w-${field.id}`, e.detail.newSize + "px");
container.onColumnResize = (field, e) => {
const idx = state.fields.findIndex((f) => f.id === field.id);
if (idx !== -1) {
state.fields[idx].width = e.detail.newSize;
state.fields = [...state.fields];
}
};
let _editorOverlay, currentEditingNode = null;
container.format = (val, field) => {
if (field.formatter) return field.formatter(val, field);
return val == null ? "" : typeof val === "object" ? JSON.stringify(val) : String(val);
};
container.onScroll = () => {
perf.onScroll();
scroll.refresh();
container.hideColumnMenu();
const prev = container.querySelector(".dt-spacer-prev"), post = container.querySelector(".dt-spacer-post");
if (prev) {
prev.style.height = (state.prevHeight || 0) + "px";
prev.style.display = state.prevHeight > 0 ? "block" : "none";
}
if (post) {
post.style.height = (state.postHeight || 0) + "px";
post.style.display = state.postHeight > 0 ? "block" : "none";
2026-05-17 17:03:21 +08:00
}
};
container.applySortFilter = (options = {}) => {
if (state._internalUpdate) return;
const targetFilters = { ...state.filterConfig, ...options.filters || {} };
const targetSort = options.sort !== void 0 ? options.sort ? { fieldId: state.activeFieldId, direction: options.sort } : { fieldId: null, direction: null } : state.sortConfig;
const currentHash = JSON.stringify({ s: targetSort, f: targetFilters });
if (state._appliedHash === currentHash && !options.force) return;
state._internalUpdate = true;
let list = [...state._originalList || []];
Object.keys(targetFilters).forEach((fieldId) => {
const filter = targetFilters[fieldId];
if (!filter) return;
const { mode = "contains", value, value2, selectedValues } = filter;
if ((selectedValues == null ? void 0 : selectedValues.length) > 0) {
list = list.filter((item) => selectedValues.includes(String(item[fieldId] ?? "")));
return;
}
if (value === "" || value == null) return;
const lowV = String(value).toLowerCase(), n1 = Number(value), n2 = Number(value2);
list = list.filter((item) => {
const iv = item[fieldId], sv = String(iv ?? "").toLowerCase();
switch (mode) {
case "contains":
return sv.includes(lowV);
case "equals":
return sv === lowV;
case "starts":
return sv.startsWith(lowV);
case "ends":
return sv.endsWith(lowV);
case ">":
return Number(iv) > n1;
case "<":
return Number(iv) < n1;
case "=":
return Number(iv) === n1;
case "between":
return Number(iv) >= n1 && Number(iv) <= n2;
default:
return sv.includes(lowV);
}
});
});
if (targetSort.fieldId && targetSort.direction) {
list.sort((a, b) => {
let va = a[targetSort.fieldId], vb = b[targetSort.fieldId];
if (va === vb) return 0;
const res = va > vb ? 1 : -1;
return targetSort.direction === "asc" ? res : -res;
});
}
state._appliedHash = currentHash;
state.sortConfig = targetSort;
state.list = list;
state._internalUpdate = false;
};
container.showColumnMenu = (field, e) => {
e.stopPropagation();
const btn = e.currentTarget, menu = container.querySelector(".dt-column-menu");
const type = field.type || "text";
state.activeModes = MODE_MAP[type] || (["boolean", "switch", "checkbox", "radio"].includes(type) ? [] : MODE_MAP.text);
if (!state.filterConfig[field.id]) {
state.filterConfig[field.id] = { mode: state.activeModes[0] || "contains", value: "", selectedValues: [] };
}
state.activeField = field;
state.activeFieldId = field.id;
menu.style.display = "block";
const cellNode = btn.closest(".dt-cell");
const rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
const menuWidth = menu.offsetWidth || 260;
let leftPos = rect.right - rootRect.left - menuWidth;
if (leftPos < 0) leftPos = Math.max(0, rect.left - rootRect.left);
menu.style.left = leftPos + "px";
menu.style.top = rect.bottom - rootRect.top + 5 + "px";
const onGlobalClick = (ev) => {
if (menu.contains(ev.target) || btn.contains(ev.target)) return;
container.hideColumnMenu();
container.applySortFilter();
document.removeEventListener("mousedown", onGlobalClick);
};
document.addEventListener("mousedown", onGlobalClick);
setTimeout(() => {
var _a;
return (_a = menu.querySelector("input")) == null ? void 0 : _a.focus();
}, 50);
};
container.toggleSelectedValue = (val) => {
const filter = state.filterConfig[state.activeFieldId];
if (!filter) return;
const idx = filter.selectedValues.indexOf(val);
if (idx === -1) filter.selectedValues.push(val);
else filter.selectedValues.splice(idx, 1);
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
};
container.filterOnlyThis = (val) => {
state.filterConfig[state.activeFieldId] = { mode: "contains", value: "", selectedValues: [String(val)] };
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
};
container.hideColumnMenu = () => {
const menu = container.querySelector(".dt-column-menu");
if (menu) menu.style.display = "none";
};
container.setSort = (dir) => {
const newDir = state.sortConfig.direction === dir && state.sortConfig.fieldId === state.activeFieldId ? null : dir;
container.applySortFilter({ sort: newDir });
};
container.clearColumnSettings = () => {
if (state.activeFieldId) {
delete state.filterConfig[state.activeFieldId];
state.filterConfig = { ...state.filterConfig };
container.applySortFilter();
}
};
container._initRow = (rowNode) => {
var _a;
const row = (_a = rowNode._ref) == null ? void 0 : _a.item;
if (row && row._editingF === void 0) {
Object.defineProperty(row, "_editingF", { set: (v) => {
if (v === null) container.hideEditor(true);
}, configurable: true });
}
Array.from(rowNode.children).forEach((cell) => {
const fIdx = parseInt(cell.dataset.fidx);
if (!isNaN(fIdx)) cell._refExt = { f: state.fields[fIdx], fIdx };
});
};
state.__watch("fields", (fields) => {
if (!fields) return;
state._fieldsDirty = true;
container.style.setProperty("--dt-grid-template", fields.map((f) => `var(--w-${f.id}, ${f.width || 150}px)`).join(" "));
container.style.setProperty("--dt-row-width", fields.reduce((sum, f) => sum + (f.width || 150), 0) + "px");
let leftSum = 0;
fields.forEach((f) => {
if (f.pinned === "left") {
container.style.setProperty(`--l-${f.id}`, leftSum + "px");
leftSum += f.width || 150;
}
});
let rightSum = 0;
[...fields].reverse().forEach((f) => {
if (f.pinned === "right") {
container.style.setProperty(`--r-${f.id}`, rightSum + "px");
rightSum += f.width || 150;
}
});
});
state.__watch("list", (list) => {
var _a;
if (state._fieldsDirty) {
state._fieldsDirty = false;
const fieldTemplate = (_a = container.querySelector('.dt-body template[index="rIdx"]')) == null ? void 0 : _a.content.querySelector('template[as="f"]');
if (fieldTemplate) {
const masters = state._masterCellNodes || (state._masterCellNodes = Array.from(fieldTemplate.content.childNodes).map((n) => n.cloneNode(true)));
fieldTemplate.removeAttribute("$each");
fieldTemplate.setAttribute("$if", "true");
fieldTemplate.content.textContent = "";
state.fields.forEach((f, fIdx) => masters.forEach((master) => {
const clone = master.cloneNode(true);
if (clone.nodeType === 1) clone.dataset.fidx = fIdx;
fieldTemplate.content.appendChild(clone);
}));
}
}
if (!state._internalUpdate) {
state._originalList = [...list || []];
setTimeout(() => {
const stats = {};
state.fields.forEach((f) => {
const counts = {};
state._originalList.forEach((item) => {
const val = item[f.id], key = val == null || val === "" ? "" : String(val);
counts[key] = (counts[key] || 0) + 1;
});
stats[f.id] = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([val, count]) => ({ val, count }));
});
state._columnStats = stats;
}, 200);
}
scroll.init();
scroll.reset(list);
});
container.editCell = (row, field, cellNode) => {
const overlay = container.querySelector(".dt-editor-overlay"), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
currentEditingNode = cellNode;
Object.assign(overlay.style, {
display: "flex",
left: rect.left - rootRect.left + "px",
top: rect.top - rootRect.top + "px",
width: (field.type === "textarea" || field.type === "TagsInput" ? Math.max(rect.width, 300) : rect.width) + "px",
height: field.type === "textarea" || field.type === "TagsInput" ? "auto" : rect.height + "px"
});
State.editingSchema = [{ ...field, name: field.id, label: "" }];
State.editingData = row;
setTimeout(() => {
var _a;
return (_a = overlay.querySelector("input, textarea, select, .form-control")) == null ? void 0 : _a.focus();
}, 30);
};
container.hideEditor = (save = true) => {
if (!_editorOverlay) _editorOverlay = container.querySelector(".dt-editor-overlay");
if (!_editorOverlay || _editorOverlay.style.display === "none") return;
if (save) {
const input = _editorOverlay.querySelector("input:focus, select:focus, textarea:focus");
if (input) input.dispatchEvent(new Event(input.type === "number" || input.tagName === "SELECT" ? "change" : "input", { bubbles: true }));
if (currentEditingNode) RefreshState(currentEditingNode);
}
_editorOverlay.style.display = "none";
State.editingSchema = State.editingData = currentEditingNode = null;
container.focus();
};
container.onMainMouseDown = (e) => {
var _a;
const cell = e.target.closest(".dt-cell"), row = cell == null ? void 0 : cell.closest(".dt-row");
if (!row || row.classList.contains("dt-header-row")) return;
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll(".dt-cell")).indexOf(cell);
const rIdx = ((_a = row._ref) == null ? void 0 : _a.rIdx) ?? Array.from(container.querySelectorAll(".dt-body-row")).indexOf(row);
selection.startSelect(rIdx + state._listStartIndex, fIdx, e);
};
container.onMainMouseOver = (e) => {
var _a;
if (state.isSelecting) {
const cell = e.target.closest(".dt-cell"), row = cell == null ? void 0 : cell.closest(".dt-row");
if (row && !row.classList.contains("dt-header-row")) {
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll(".dt-cell")).indexOf(cell);
const rIdx = ((_a = row._ref) == null ? void 0 : _a.rIdx) ?? Array.from(container.querySelectorAll(".dt-body-row")).indexOf(row);
selection.updateSelect(rIdx + state._listStartIndex, fIdx);
}
}
2026-05-17 17:03:21 +08:00
};
container.onMainDblClick = (e) => {
var _a;
const cell = e.target.closest(".dt-cell"), row = cell == null ? void 0 : cell.closest(".dt-row");
if (row && !row.classList.contains("dt-header-row")) {
const item = (_a = row._ref) == null ? void 0 : _a.item, fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll(".dt-cell")).indexOf(cell);
if (item && state.fields[fIdx]) container.editCell(item, state.fields[fIdx], cell);
}
};
window.addEventListener("mouseup", selection.endSelect);
document.addEventListener("mousedown", (e) => {
const overlay = container.querySelector(".dt-editor-overlay");
if ((overlay == null ? void 0 : overlay.style.display) !== "none" && !overlay.contains(e.target)) container.hideEditor(true);
if (!container.contains(e.target) && !(overlay == null ? void 0 : overlay.contains(e.target))) selection.clearAllActive();
});
state._MODE_ICONS = MODE_ICONS;
2026-05-17 17:03:21 +08:00
}, Util.makeDom(
/*html*/
`
<!--
NOTE: For $class and $style directives, ALWAYS use the template literal syntax:
$class="base-class \${condition ? 'active' : ''}"
DO NOT use string concatenation like $class="'base-class ' + (condition ? 'active' : '')".
Since the HTML is wrapped in backticks (``), remember to escape the dollar sign: \${ }
-->
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-hidden" style="position:relative; user-select:none; outline: none; min-height: 0" tabindex="0">
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
$onmousedown="this.onMainMouseDown(event)" $onmouseover="this.onMainMouseOver(event)" $ondblclick="this.onMainDblClick(event)"
style="overflow-anchor:none; min-height: 0">
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
<div class="dt-header-row fw-bold text-muted small">
<template $each="this.state?.fields || []">
<div $data-id="item.id" $class="dt-cell dt-col border-end d-flex align-items-center header-cell \${item.pinned ? 'pinned-' + item.pinned : ''}" $style="(item.pinned ? 'position: sticky; z-index: 11; background-color: inherit; ' : 'position:relative; ') + 'padding: 0; ' + (item.pinned === 'left' ? 'left: var(--l-' + item.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : (item.pinned === 'right' ? 'right: var(--r-' + item.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))">
<div class="d-flex align-items-center overflow-hidden flex-grow-1 h-100 px-2 cursor-pointer" $onclick="this.showColumnMenu(item, event)">
<i $if="this.state?.filterConfig?.[item.id] && (this.state.filterConfig[item.id].value || this.state.filterConfig[item.id].selectedValues?.length)" class="bi bi-filter me-1 text-primary"></i>
<i $if="this.state?.sortConfig?.fieldId === item.id && this.state.sortConfig.direction" $class="bi bi-sort-\${this.state.sortConfig.direction === 'asc' ? 'down' : 'up-alt'} me-1 text-primary"></i>
<span $text="item.name" class="text-truncate flex-grow-1"></span>
</div>
<button class="btn btn-xs btn-link text-muted p-0 border-0 me-1 header-menu-btn" $onclick="this.showColumnMenu(item, event)"><i class="bi bi-chevron-down"></i></button>
<Resizer $.target="thisNode.parentElement" style="position:absolute; right:0; top:0; bottom:0; width:4px; z-index:10" min="50" max="1000" $onresizing="this.onColumnResizing(item, event)" $onresize="this.onColumnResize(item, event)"></Resizer>
</div>
</template>
</div>
</div>
<div class="dt-body" style="position:relative">
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
<template $each="this.state?._renderedList || []" key="id" index="rIdx">
<div class="dt-row dt-body-row border-bottom bg-white" $.="this._initRow(thisNode)">
<template as="f"><div $class="dt-cell border-end px-2 d-flex align-items-center \${f.pinned ? 'pinned-' + f.pinned : ''}" $style="(f.pinned ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + (f.pinned === 'left' ? 'left: var(--l-' + f.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : (f.pinned === 'right' ? 'right: var(--r-' + f.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))"><span $text="this.format(item[f.id], f)" class="text-truncate"></span></div></template>
2026-05-17 17:03:21 +08:00
</div>
</template>
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
</div>
</div>
<div class="dt-column-menu border bg-body shadow-lg rounded p-3" style="display:none; position:absolute; z-index:2000; min-width:260px; max-width:320px;">
<template $if="this.state?.activeFieldId">
<div class="btn-group w-100 mb-3">
<button $class="btn btn-sm d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'asc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('asc')"><i class="bi bi-sort-alpha-down me-1"></i> ASC</button>
<button $class="btn btn-sm d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'desc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('desc')"><i class="bi bi-sort-alpha-up-alt me-1"></i> DESC</button>
</div>
<div $if="this.state?.activeModes?.length" class="dt-filter-tabs d-flex overflow-auto border-bottom bg-light-subtle rounded-top py-1" style="white-space:nowrap; scrollbar-width: none;">
<template $each="this.state?.activeModes || []" as="m">
<div $class="px-2 py-1 cursor-pointer fs-5 \${this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === m ? 'text-primary border-bottom border-primary border-2' : 'text-muted'}" $title="m.toUpperCase()" $onclick="this.state.filterConfig[this.state.activeFieldId].mode = m; this.state.filterConfig = {...this.state.filterConfig}">
<i $class="bi \${this.state?._MODE_ICONS?.[m] || 'bi-filter'}"></i>
</div>
</template>
</div>
<template $if="this.state?.activeModes?.length">
<div class="py-2 border-bottom" style="min-height: 48px">
<input type="text" class="form-control form-control-sm mb-1" $placeholder="(this.state?.filterConfig?.[this.state?.activeFieldId]?.mode || 'Search').toUpperCase() + '...'" $bind="this.state?.filterConfig?.[this.state?.activeFieldId].value" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
<input $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === 'between'" type="text" class="form-control form-control-sm" placeholder="And..." $bind="this.state?.filterConfig?.[this.state?.activeFieldId].value2" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
</div>
</template>
<div class="mt-3" style="max-height: 200px; overflow-y: auto;">
<div class="text-muted fw-bold mb-2" style="font-size: 10px; letter-spacing: 0.5px">TOP FREQUENT VALUES</div>
<template $each="this.state?._columnStats?.[this.state?.activeFieldId] || []">
<label class="d-flex align-items-center mb-1 small cursor-pointer p-1 rounded-1 menu-item-row" onmouseover="this.style.background='var(--bs-light)'" onmouseout="this.style.background='transparent'">
<input type="checkbox" class="form-check-input me-2" $checked="this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.includes(String(item.val))" $onclick="this.toggleSelectedValue(String(item.val))">
<span class="text-truncate flex-grow-1"><span $text="item.val || '(Empty)'"></span> <span class="text-muted ms-1" style="font-size: 0.7rem" $text="'(' + item.count + ')'"></span></span>
<button class="btn btn-xs btn-link p-0 text-primary only-btn" style="font-size: 10px; text-decoration: none" $onclick="this.filterOnlyThis(item.val); event.preventDefault(); event.stopPropagation();">Only</button>
</label>
</template>
</div>
<div $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.value || this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.length" class="mt-3 pt-2 border-top text-center">
<span class="cursor-pointer text-primary small fw-bold" $onclick="this.clearColumnSettings()"><i class="bi bi-x-circle me-1"></i> Clear Filter</span>
</div>
</template>
2026-05-17 17:03:21 +08:00
</div>
<div class="dt-editor-overlay dt-editor-container" style="display: none; position: absolute; z-index: 1000; background: var(--bs-body-bg); box-shadow: 0 4px 16px rgba(0,0,0,0.25); border: 1px solid var(--bs-primary);"><AutoForm inline class="h-100 w-100" $onsubmit="this.hideEditor(true)"/></div>
<div class="dt-footer border-top bg-light d-flex align-items-center px-2 py-1 small text-muted" style="height:32px"><span $text="(this.state?.selectedRowCount || 0) + ' / ' + (this.state?.list?.length || 0)"></span></div>
2026-05-17 17:03:21 +08:00
</div>
`
), Util.makeDom(
/*html*/
`
<style>
DataTable { display: block; }
.dt-root { font-size: 0.875rem; }
.dt-row, .dt-header-row { display: grid; grid-template-columns: var(--dt-grid-template); width: var(--dt-row-width, max-content); min-width: 100%; height: 40px; contain: paint layout; }
.dt-header-row { background-color: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); }
.dt-cell { background: inherit; white-space: nowrap; flex-shrink: 0; contain: content; }
.dt-cell-selected { background-color: rgba(var(--bs-primary-rgb), 0.15) !important; outline: 1px solid var(--bs-primary); outline-offset: -1px; }
.dt-body-row:hover { background-color: var(--bs-secondary-bg) !important; }
.header-cell .header-menu-btn { opacity: 0; transition: opacity 0.2s; }
.header-cell:hover .header-menu-btn { opacity: 1; }
.dt-column-menu { background-color: var(--bs-body-bg); border: 1px solid var(--bs-primary); box-shadow: 0 10px 40px rgba(0,0,0,0.2) !important; z-index: 2100 !important; }
.btn-xs { padding: 1px 5px; line-height: 1.5; }
.cursor-pointer { cursor: pointer; }
.dt-filter-tabs i { font-size: 1.1rem; }
.dt-filter-tabs div:hover i { color: var(--bs-primary); }
.menu-item-row .only-btn { opacity: 0; }
.menu-item-row:hover .only-btn { opacity: 1; }
</style>
`
2026-05-17 17:03:21 +08:00
));