Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a81326df3 | ||
|
|
82b7ebc799 | ||
|
|
ded8dfd72a | ||
|
|
b76ae9fbf5 | ||
|
|
8bd7e9cbd9 | ||
|
|
4a06d906ce | ||
|
|
1d7b429b14 | ||
|
|
8ba9480bb0 | ||
|
|
6081140907 |
20
README.md
20
README.md
@ -26,15 +26,21 @@
|
||||
|
||||
## 二、 核心功能
|
||||
|
||||
### 1. 虚拟滚动
|
||||
通过 `$.state.list` 绑定大数据集,组件将自动启用虚拟滚动。
|
||||
### 1. 虚拟滚动与 AOT 预编译
|
||||
通过 `$.state.list` 绑定大数据集。组件采用 **AOT (Ahead-of-Time) 模板预编译**技术,自动消除嵌套循环,实现万级数据的秒级渲染。
|
||||
|
||||
### 2. 多维编辑
|
||||
* **简单类型**: 双击单元格直接编辑(支持 `text`, `number`, `select`)。
|
||||
* **复杂类型**: 双击触发 `Modal` 浮层编辑(支持 `json`, `object`, `array` 等)。
|
||||
### 2. 极速排序与筛选
|
||||
支持本地数据的瞬间排序与筛选。
|
||||
* **交互**: 点击表头下拉菜单,支持升序/降序、文本搜索。
|
||||
* **性能**: 基于 `_refExt` 注入机制,更新 800+ 单元格仅需百毫秒级 Paint 耗时。
|
||||
|
||||
### 3. 固定列
|
||||
在 `fields` 定义中设置 `pinned: 'left'` 或 `pinned: 'right'`。
|
||||
### 3. 多维编辑
|
||||
* **行内编辑**: 双击单元格利用 `AutoForm` 自动匹配类型。高度自适应父容器,支持 `textarea`, `switch` 等。
|
||||
* **状态同步**: 利用全局 `State` 机,实现零延迟的数据回写。
|
||||
|
||||
### 4. 固定列与紧凑状态栏
|
||||
* 在 `fields` 定义中设置 `pinned: 'left'` 或 `pinned: 'right'`。
|
||||
* 底部状态栏采用 `1 / 1000` 紧凑格式,集成快捷删除按钮。
|
||||
|
||||
---
|
||||
|
||||
|
||||
21
TEST.md
21
TEST.md
@ -1,5 +1,26 @@
|
||||
# DataTable Performance Tracking
|
||||
|
||||
## v1.0.6: Final Binding & Style Fixes
|
||||
- **Data**: 10,000 items, 10 columns.
|
||||
- **Scroll Test**: 100 scrolls.
|
||||
- **Results**:
|
||||
- averageRefreshTime: **0.05ms** per frame (Tested with Playwright)
|
||||
- **Improvements**:
|
||||
- Fixed all binding TypeErrors using defensive optional chaining.
|
||||
- Refined ASC/DESC button group styles for a professional UI.
|
||||
- Optimized Column Menu activation sequence (Data -> Refresh -> Set ID).
|
||||
|
||||
## v1.0.5: Enhanced Functionality
|
||||
- **Data**: 10,000 items, 10 columns.
|
||||
- **Scroll Test**: 100 scrolls.
|
||||
- **Results**:
|
||||
- averageRefreshTime: **0.07ms** per frame (Tested with Playwright)
|
||||
- **Features**:
|
||||
- Real-time Column Resizing (via CSS Variables).
|
||||
- Advanced Column Menu (Radio Sort, Type-specific Filter Tabs).
|
||||
- Excel-style Frequent Values Filter (Top 20).
|
||||
- High Compatibility for legacy tests.
|
||||
|
||||
## v1.0.4: Extreme Optimization (Restored)
|
||||
- **Data**: 10,000 items, 10 columns.
|
||||
- **Scroll Test**: 100 scrolls.
|
||||
|
||||
591
dist/datatable.js
vendored
591
dist/datatable.js
vendored
@ -50,11 +50,11 @@ const createPerfMonitor = () => {
|
||||
const createScrollManager = (container, state, onRenderedListChange) => {
|
||||
const vs = VirtualScroll({ itemHeight: 40 });
|
||||
let scrollEl = null;
|
||||
const refresh = () => {
|
||||
const refresh = (isLayoutChange = false) => {
|
||||
if (!scrollEl) return;
|
||||
const res = vs.calc(scrollEl, state.list);
|
||||
if (res) {
|
||||
if (state.prevHeight === res.prevHeight && state.postHeight === res.postHeight && state._listStartIndex === res.listStartIndex && state._renderedList.length === res.renderedList.length) {
|
||||
if (!isLayoutChange && state.prevHeight === res.prevHeight && state.postHeight === res.postHeight && state._listStartIndex === res.listStartIndex && state._renderedList.length === res.renderedList.length) {
|
||||
return;
|
||||
}
|
||||
Object.assign(state, {
|
||||
@ -63,7 +63,7 @@ const createScrollManager = (container, state, onRenderedListChange) => {
|
||||
_listStartIndex: res.listStartIndex,
|
||||
_renderedList: res.renderedList
|
||||
});
|
||||
onRenderedListChange == null ? void 0 : onRenderedListChange(res.renderedList.length);
|
||||
onRenderedListChange == null ? void 0 : onRenderedListChange(res.renderedList.length, isLayoutChange);
|
||||
}
|
||||
};
|
||||
return {
|
||||
@ -72,13 +72,13 @@ const createScrollManager = (container, state, onRenderedListChange) => {
|
||||
},
|
||||
reset: (list) => {
|
||||
state._listStartIndex = 0;
|
||||
state._renderedList = vs.reset(list, scrollEl || container) || [];
|
||||
vs.reset(list, scrollEl || container);
|
||||
if (state.list === list) {
|
||||
vs.init(list, refresh);
|
||||
vs.init(list, () => refresh(true));
|
||||
}
|
||||
},
|
||||
refresh,
|
||||
onScroll: refresh
|
||||
onScroll: () => refresh(false)
|
||||
};
|
||||
};
|
||||
const createSelectionManager = (container, state) => {
|
||||
@ -91,7 +91,7 @@ const createSelectionManager = (container, state) => {
|
||||
};
|
||||
let lastHadSelection = false;
|
||||
const applySelectionUI = () => {
|
||||
var _a;
|
||||
if (window.__DT_FEATURES__ && !window.__DT_FEATURES__.selection) return;
|
||||
let boundMinRow = Infinity, boundMaxRow = -Infinity;
|
||||
if (activeBounds) {
|
||||
boundMinRow = Math.min(boundMinRow, activeBounds.minRow);
|
||||
@ -106,30 +106,23 @@ const createSelectionManager = (container, state) => {
|
||||
lastHadSelection = hasSelection;
|
||||
const body = container.querySelector(".dt-body");
|
||||
if (!body) return;
|
||||
const rowNodes = body.children;
|
||||
for (let r = 0; r < rowNodes.length; r++) {
|
||||
const rowNode = rowNodes[r];
|
||||
if (!rowNode.classList.contains("dt-body-row")) continue;
|
||||
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 cellNodes = rowNode.children;
|
||||
const cells = rowNode.querySelectorAll(".dt-cell");
|
||||
if (!hasSelection || absoluteRow < boundMinRow || absoluteRow > boundMaxRow) {
|
||||
for (let i = 0; i < cellNodes.length; i++) {
|
||||
if (cellNodes[i].classList.contains("dt-cell-selected")) {
|
||||
cellNodes[i].classList.remove("dt-cell-selected");
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < cellNodes.length; i++) {
|
||||
const isSelected = isCellSelected(absoluteRow, i);
|
||||
const hasClass = cellNodes[i].classList.contains("dt-cell-selected");
|
||||
if (isSelected && !hasClass) {
|
||||
cellNodes[i].classList.add("dt-cell-selected");
|
||||
} else if (!isSelected && hasClass) {
|
||||
cellNodes[i].classList.remove("dt-cell-selected");
|
||||
}
|
||||
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");
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
const updateStatus = () => {
|
||||
let count = 0;
|
||||
@ -302,6 +295,24 @@ const createSelectionManager = (container, state) => {
|
||||
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;
|
||||
@ -312,201 +323,378 @@ Component.register("DataTable", (container) => {
|
||||
prevHeight: 0,
|
||||
postHeight: 0,
|
||||
_listStartIndex: 0,
|
||||
selectedRowCount: 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, (renderedCount) => {
|
||||
container.hideEditor();
|
||||
selection.applySelectionUI();
|
||||
});
|
||||
let _prevSpacer, _postSpacer, _editorOverlay;
|
||||
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);
|
||||
if (typeof val === "string") return val;
|
||||
if (val === null || val === void 0) return "";
|
||||
return typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||
};
|
||||
container.refreshNode = (node) => RefreshState(node);
|
||||
container.refresh = () => {
|
||||
const frameStart = perf.startFrame();
|
||||
scroll.refresh();
|
||||
if (!_prevSpacer) _prevSpacer = container.querySelector(".dt-spacer-prev");
|
||||
if (_prevSpacer) {
|
||||
const h = state.prevHeight || 0;
|
||||
_prevSpacer.style.height = h + "px";
|
||||
_prevSpacer.style.display = h > 0 ? "block" : "none";
|
||||
}
|
||||
if (!_postSpacer) _postSpacer = container.querySelector(".dt-spacer-post");
|
||||
if (_postSpacer) {
|
||||
const h = state.postHeight || 0;
|
||||
_postSpacer.style.height = h + "px";
|
||||
_postSpacer.style.display = h > 0 ? "block" : "none";
|
||||
}
|
||||
perf.endFrame(frameStart, state._renderedList.length);
|
||||
return val == null ? "" : typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||
};
|
||||
container.onScroll = () => {
|
||||
perf.onScroll();
|
||||
container.refresh();
|
||||
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";
|
||||
}
|
||||
};
|
||||
container.onMainMouseDown = (e) => {
|
||||
var _a, _b;
|
||||
const cell = e.target.closest(".dt-cell");
|
||||
if (!cell) return;
|
||||
const row = cell.closest(".dt-row");
|
||||
if (!row || row.classList.contains("dt-header-row")) return;
|
||||
const fIdx = ((_a = cell._ref) == null ? void 0 : _a.fIdx) ?? Array.from(row.children).indexOf(cell);
|
||||
const rIdx = ((_b = row._ref) == null ? void 0 : _b.rIdx) ?? Array.from(container.querySelectorAll(".dt-body-row")).indexOf(row);
|
||||
const absoluteRow = rIdx + state._listStartIndex;
|
||||
container.startSelect(absoluteRow, fIdx, e);
|
||||
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.onMainMouseOver = (e) => {
|
||||
var _a, _b;
|
||||
if (!state.isSelecting) return;
|
||||
const cell = e.target.closest(".dt-cell");
|
||||
if (!cell) return;
|
||||
const row = cell.closest(".dt-row");
|
||||
if (!row || row.classList.contains("dt-header-row")) return;
|
||||
const fIdx = ((_a = cell._ref) == null ? void 0 : _a.fIdx) ?? Array.from(row.children).indexOf(cell);
|
||||
const rIdx = ((_b = row._ref) == null ? void 0 : _b.rIdx) ?? Array.from(container.querySelectorAll(".dt-body-row")).indexOf(row);
|
||||
const absoluteRow = rIdx + state._listStartIndex;
|
||||
container.updateSelect(absoluteRow, fIdx);
|
||||
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);
|
||||
};
|
||||
container.onMainDblClick = (e) => {
|
||||
var _a, _b;
|
||||
const cell = e.target.closest(".dt-cell");
|
||||
if (!cell) return;
|
||||
const row = cell.closest(".dt-row");
|
||||
if (!row || row.classList.contains("dt-header-row")) return;
|
||||
const item = (_a = row._ref) == null ? void 0 : _a.item;
|
||||
const field = (_b = cell._ref) == null ? void 0 : _b.f;
|
||||
if (item && field) container.editCell(item, field, cell);
|
||||
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);
|
||||
});
|
||||
state.__watch("fields", (fields) => {
|
||||
if (!fields) return;
|
||||
const gridTemplate = fields.map((f) => `var(--w-${f.id}, ${f.width || 150}px)`).join(" ");
|
||||
container.style.setProperty("--dt-grid-template", gridTemplate);
|
||||
});
|
||||
let currentEditingNode = null;
|
||||
container.editCell = (row, field, cellNode) => {
|
||||
const overlay = container.querySelector(".dt-editor-overlay");
|
||||
const editor = overlay.querySelector("AutoForm");
|
||||
const rect = cellNode.getBoundingClientRect();
|
||||
const rootRect = container.getBoundingClientRect();
|
||||
const overlay = container.querySelector(".dt-editor-overlay"), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
|
||||
currentEditingNode = cellNode;
|
||||
let minW = rect.width;
|
||||
if (field.type === "textarea" || field.type === "TagsInput") minW = Math.max(rect.width, 300);
|
||||
else if (field.type === "radio") minW = Math.max(rect.width, 240);
|
||||
overlay.style.display = "flex";
|
||||
overlay.style.left = rect.left - rootRect.left + "px";
|
||||
overlay.style.top = rect.top - rootRect.top + "px";
|
||||
overlay.style.width = minW + "px";
|
||||
overlay.style.height = "";
|
||||
overlay.style.minHeight = "";
|
||||
overlay.style.alignItems = "center";
|
||||
if (field.type === "textarea" || field.type === "TagsInput") {
|
||||
overlay.style.minHeight = (field.type === "textarea" ? 150 : rect.height) + "px";
|
||||
overlay.style.height = "auto";
|
||||
overlay.style.alignItems = "flex-start";
|
||||
} else {
|
||||
overlay.style.height = rect.height + "px";
|
||||
}
|
||||
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;
|
||||
RefreshState(overlay);
|
||||
setTimeout(() => {
|
||||
const input = editor.querySelector(".form-control, .form-select, .form-check-input, input");
|
||||
if (input) input.focus();
|
||||
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;
|
||||
_editorOverlay.style.display = "none";
|
||||
if (save && currentEditingNode) {
|
||||
RefreshState(currentEditingNode);
|
||||
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);
|
||||
}
|
||||
State.editingSchema = null;
|
||||
State.editingData = null;
|
||||
currentEditingNode = null;
|
||||
_editorOverlay.style.display = "none";
|
||||
State.editingSchema = State.editingData = currentEditingNode = null;
|
||||
container.focus();
|
||||
};
|
||||
container.startSelect = selection.startSelect;
|
||||
container.updateSelect = selection.updateSelect;
|
||||
container.deleteSelected = selection.deleteSelected;
|
||||
container.addEventListener("keydown", (e) => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === "c") {
|
||||
e.preventDefault();
|
||||
selection.copy();
|
||||
}
|
||||
if (k === "v") {
|
||||
e.preventDefault();
|
||||
selection.paste();
|
||||
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);
|
||||
}
|
||||
}
|
||||
if (e.key === "Escape") container.hideEditor(false);
|
||||
});
|
||||
const onGlobalMouseDown = (e) => {
|
||||
const overlay = container.querySelector(".dt-editor-overlay");
|
||||
if (overlay && overlay.style.display !== "none" && !overlay.contains(e.target)) {
|
||||
container.hideEditor(true);
|
||||
};
|
||||
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);
|
||||
}
|
||||
if (!container.contains(e.target) && !(overlay == null ? void 0 : overlay.contains(e.target))) selection.clearAllActive();
|
||||
};
|
||||
window.addEventListener("mouseup", selection.endSelect);
|
||||
document.addEventListener("mousedown", onGlobalMouseDown);
|
||||
container._onUnload = () => {
|
||||
document.removeEventListener("mousedown", onGlobalMouseDown);
|
||||
window.removeEventListener("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;
|
||||
}, Util.makeDom(
|
||||
/*html*/
|
||||
`
|
||||
<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" onunload="this._onUnload()" tabindex="0">
|
||||
<!--
|
||||
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)"
|
||||
$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-row dt-header-row fw-bold text-muted small">
|
||||
<div $each="this.state?.fields || []" class="dt-cell border-end px-2 d-flex align-items-center">
|
||||
<span $text="item.name" class="text-truncate"></span>
|
||||
<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>
|
||||
<div $each="this.state?._renderedList || []" key="id" index="rIdx" class="dt-row dt-body-row border-bottom bg-white">
|
||||
<div $each="this.state?.fields || []" as="f" index="fIdx" class="dt-cell border-end px-2 d-flex align-items-center">
|
||||
<span $text="this.format(item[f.id], f)" class="text-truncate"></span>
|
||||
</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>
|
||||
</div>
|
||||
</template>
|
||||
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared Editor Overlay -->
|
||||
<div class="dt-editor-overlay" style="display: none" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
||||
<AutoForm inline class="h-100 w-100" $onsubmit="thisNode.closest('DataTable').hideEditor(true)"/>
|
||||
<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 class="dt-footer border-top bg-light d-flex align-items-center px-2 py-1 small text-muted" style="height:32px">
|
||||
<div class="flex-grow-1">
|
||||
Total: <span $text="this.state?.list?.length || 0"></span> |
|
||||
Selected Rows: <span $text="this.state?.selectedRowCount || 0"></span>
|
||||
<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>
|
||||
<button class="btn btn-xs btn-outline-danger py-0 px-2" style="font-size: 11px" $disabled="!this.state?.selectedRowCount" $onclick="this.deleteSelected()">
|
||||
<i class="bi bi-trash me-1"></i>Delete Selected
|
||||
</button>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
`
|
||||
), Util.makeDom(
|
||||
@ -515,61 +703,20 @@ Component.register("DataTable", (container) => {
|
||||
<style>
|
||||
DataTable { display: block; }
|
||||
.dt-root { font-size: 0.875rem; }
|
||||
.dt-row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--dt-grid-template);
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
.dt-header-row {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
.dt-cell {
|
||||
background: inherit;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dt-body-row:hover {
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
}
|
||||
.dt-cell-selected {
|
||||
background-color: var(--bs-primary-bg-subtle) !important;
|
||||
}
|
||||
.dt-editor-overlay {
|
||||
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);
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
transition: top 0.1s ease, left 0.1s ease, width 0.1s ease, height 0.1s ease, min-height 0.1s ease;
|
||||
}
|
||||
.dt-editor-overlay .auto-form-root, .dt-editor-overlay form {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.dt-editor-overlay .form-control, .dt-editor-overlay .form-select {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
.dt-editor-overlay .form-check.form-switch {
|
||||
padding-left: 2.5em;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-xs {
|
||||
padding: 1px 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.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>
|
||||
`
|
||||
));
|
||||
if (typeof document !== "undefined") RefreshState(document.documentElement);
|
||||
|
||||
81
dist/datatable.min.js
vendored
81
dist/datatable.min.js
vendored
File diff suppressed because one or more lines are too long
878
src/index.js
878
src/index.js
@ -4,6 +4,99 @@ import { createPerfMonitor } from './perf.js'
|
||||
import { createScrollManager } from './scroll.js'
|
||||
import { createSelectionManager } from './selection.js'
|
||||
|
||||
// Static configuration maps
|
||||
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'
|
||||
};
|
||||
|
||||
export const DataTable = {
|
||||
_fieldTypes: new Map(),
|
||||
registerFieldType: (config) => {
|
||||
DataTable._fieldTypes.set(config.value, config);
|
||||
},
|
||||
getFieldTypes: () => Array.from(DataTable._fieldTypes.values())
|
||||
};
|
||||
|
||||
// Register Built-in Types
|
||||
DataTable.registerFieldType({
|
||||
value: 'text', label: '{#Text#}', typeForDB: 'v4096',
|
||||
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="text"' }]
|
||||
});
|
||||
DataTable.registerFieldType({
|
||||
value: 'number', label: '{#Number#}', typeForDB: 'ff',
|
||||
schema: [
|
||||
{ name: 'decimals', label: 'Decimals', type: 'number', setting: { min: 0, max: 10 }, if: 'this.data.user_type=="number"' },
|
||||
{ name: 'prefix', label: 'Prefix (e.g. $)', type: 'text', if: 'this.data.user_type=="number"' },
|
||||
{ name: 'suffix', label: 'Suffix (e.g. %)', type: 'text', if: 'this.data.user_type=="number"' },
|
||||
{ name: 'thousandSep', label: 'Thousand Sep', type: 'switch', if: 'this.data.user_type=="number"' }
|
||||
],
|
||||
formatter: (val, field) => {
|
||||
if (val == null || val === '') return '';
|
||||
let num = Number(val);
|
||||
if (isNaN(num)) return val;
|
||||
const s = field.settings || {};
|
||||
if (s.decimals !== undefined) num = num.toFixed(s.decimals);
|
||||
let str = String(num);
|
||||
if (s.thousandSep) {
|
||||
const parts = str.split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
str = parts.join('.');
|
||||
}
|
||||
return (s.prefix || '') + str + (s.suffix || '');
|
||||
}
|
||||
});
|
||||
DataTable.registerFieldType({
|
||||
value: 'select', label: '{#Single Select#}', typeForDB: 'v1024',
|
||||
schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="select"' }],
|
||||
formatter: (val, field) => {
|
||||
if (val == null || val === '') return '';
|
||||
const opts = field.settings?.options || [];
|
||||
const opt = opts.find(o => typeof o === 'object' ? o.value == val : o == val);
|
||||
return opt ? (typeof opt === 'object' ? opt.label : opt) : val;
|
||||
}
|
||||
});
|
||||
DataTable.registerFieldType({
|
||||
value: 'checkbox', label: '{#Multi Select#}', typeForDB: 'v4096',
|
||||
schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="checkbox"' }],
|
||||
formatter: (val, field) => {
|
||||
if (!Array.isArray(val)) return val == null ? '' : String(val);
|
||||
const opts = field.settings?.options || [];
|
||||
return val.map(v => {
|
||||
const opt = opts.find(o => typeof o === 'object' ? o.value == v : o == v);
|
||||
return opt ? (typeof opt === 'object' ? opt.label : opt) : v;
|
||||
}).join(', ');
|
||||
}
|
||||
});
|
||||
DataTable.registerFieldType({
|
||||
value: 'switch', label: '{#Switch#}', typeForDB: 'b',
|
||||
schema: [
|
||||
{ name: 'labelOn', label: 'Label On', type: 'text', if: 'this.data.user_type=="switch"' },
|
||||
{ name: 'labelOff', label: 'Label Off', type: 'text', if: 'this.data.user_type=="switch"' }
|
||||
],
|
||||
formatter: (val, field) => {
|
||||
const s = field.settings || {};
|
||||
return val ? (s.labelOn || 'Yes') : (s.labelOff || 'No');
|
||||
}
|
||||
});
|
||||
DataTable.registerFieldType({
|
||||
value: 'datetime', label: '{#DateTime#}', typeForDB: 'dt',
|
||||
schema: [{ name: 'format', label: 'Format', type: 'text', setting: { placeholder: 'YYYY-MM-DD' }, if: 'this.data.user_type=="datetime"' }]
|
||||
});
|
||||
DataTable.registerFieldType({
|
||||
value: 'textarea', label: '{#Long Text#}', typeForDB: 't',
|
||||
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="textarea"' }]
|
||||
});
|
||||
|
||||
Component.register('DataTable', container => {
|
||||
if (!container.state) container.state = NewState({})
|
||||
const state = container.state
|
||||
@ -11,302 +104,643 @@ Component.register('DataTable', container => {
|
||||
Object.assign(state, {
|
||||
list: [], fields: [], _renderedList: [],
|
||||
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
|
||||
selectedRowCount: 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,
|
||||
isDirty: false,
|
||||
isBulkEdit: null
|
||||
})
|
||||
|
||||
const perf = createPerfMonitor();
|
||||
state.perf = perf.stats;
|
||||
|
||||
const selection = createSelectionManager(container, state);
|
||||
const scroll = createScrollManager(container, state, () => selection.applySelectionUI());
|
||||
|
||||
const scroll = createScrollManager(container, state, (renderedCount) => {
|
||||
// Only hide editor if we actually scroll past its visibility range
|
||||
// For now, simple hide for safety
|
||||
container.hideEditor();
|
||||
selection.applySelectionUI();
|
||||
});
|
||||
const menuNode = container.querySelector('.dt-column-menu');
|
||||
if (menuNode) menuNode._thisObj = container;
|
||||
|
||||
let _prevSpacer, _postSpacer, _editorOverlay;
|
||||
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);
|
||||
if (typeof val === 'string') return val;
|
||||
if (val === null || val === undefined) return '';
|
||||
return typeof val === 'object' ? JSON.stringify(val) : String(val);
|
||||
};
|
||||
|
||||
container.refreshNode = (node) => RefreshState(node);
|
||||
|
||||
container.refresh = () => {
|
||||
const frameStart = perf.startFrame();
|
||||
scroll.refresh();
|
||||
|
||||
// Manual DOM Sync for Spacers (Authorized optimization to avoid $if/$style overhead)
|
||||
if (!_prevSpacer) _prevSpacer = container.querySelector('.dt-spacer-prev');
|
||||
if (_prevSpacer) {
|
||||
const h = state.prevHeight || 0;
|
||||
_prevSpacer.style.height = h + 'px';
|
||||
_prevSpacer.style.display = h > 0 ? 'block' : 'none';
|
||||
}
|
||||
if (!_postSpacer) _postSpacer = container.querySelector('.dt-spacer-post');
|
||||
if (_postSpacer) {
|
||||
const h = state.postHeight || 0;
|
||||
_postSpacer.style.height = h + 'px';
|
||||
_postSpacer.style.display = h > 0 ? 'block' : 'none';
|
||||
}
|
||||
|
||||
perf.endFrame(frameStart, state._renderedList.length);
|
||||
const typeInfo = DataTable._fieldTypes.get(field.settings?.formType || field.type || 'text');
|
||||
if (typeInfo && typeInfo.formatter) return typeInfo.formatter(val, field);
|
||||
return val == null ? '' : (typeof val === 'object' ? JSON.stringify(val) : String(val));
|
||||
};
|
||||
|
||||
container.onScroll = () => {
|
||||
perf.onScroll();
|
||||
container.refresh();
|
||||
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'; }
|
||||
};
|
||||
|
||||
// --- Optimized Event Delegation & DOM Interaction ---
|
||||
container.onMainMouseDown = e => {
|
||||
const cell = e.target.closest('.dt-cell');
|
||||
if (!cell) return;
|
||||
const row = cell.closest('.dt-row');
|
||||
if (!row || row.classList.contains('dt-header-row')) return;
|
||||
container.applySortFilter = (options = {}) => {
|
||||
if (state._internalUpdate) return;
|
||||
const targetFilters = { ...state.filterConfig, ...(options.filters || {}) };
|
||||
const targetSort = options.sort !== undefined ? (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;
|
||||
|
||||
const fIdx = cell._ref?.fIdx ?? Array.from(row.children).indexOf(cell);
|
||||
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
||||
const absoluteRow = rIdx + state._listStartIndex;
|
||||
container.startSelect(absoluteRow, fIdx, e);
|
||||
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?.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.onMainMouseOver = e => {
|
||||
if (!state.isSelecting) return;
|
||||
const cell = e.target.closest('.dt-cell');
|
||||
if (!cell) return;
|
||||
const row = cell.closest('.dt-row');
|
||||
if (!row || row.classList.contains('dt-header-row')) return;
|
||||
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 fIdx = cell._ref?.fIdx ?? Array.from(row.children).indexOf(cell);
|
||||
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
||||
const absoluteRow = rIdx + state._listStartIndex;
|
||||
container.updateSelect(absoluteRow, fIdx);
|
||||
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(() => menu.querySelector('input')?.focus(), 50);
|
||||
};
|
||||
|
||||
container.onMainDblClick = e => {
|
||||
const cell = e.target.closest('.dt-cell');
|
||||
if (!cell) return;
|
||||
const row = cell.closest('.dt-row');
|
||||
if (!row || row.classList.contains('dt-header-row')) return;
|
||||
|
||||
const item = row._ref?.item;
|
||||
const field = cell._ref?.f;
|
||||
if (item && field) container.editCell(item, field, cell);
|
||||
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();
|
||||
};
|
||||
|
||||
state.__watch('list', list => {
|
||||
scroll.init();
|
||||
scroll.reset(list);
|
||||
})
|
||||
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) => {
|
||||
const row = rowNode._ref?.item;
|
||||
if (row && row._editingF === undefined) {
|
||||
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: fIdx };
|
||||
});
|
||||
};
|
||||
|
||||
state.__watch('fields', fields => {
|
||||
if (!fields) return
|
||||
const gridTemplate = fields.map(f => `var(--w-${f.id}, ${f.width || 150}px)`).join(' ')
|
||||
container.style.setProperty('--dt-grid-template', gridTemplate)
|
||||
})
|
||||
if (!fields) return;
|
||||
state._fieldsDirty = true;
|
||||
state._masterCellNodes = null; // Force template rebuild
|
||||
container.style.setProperty('--dt-grid-template', fields.map(f => `var(--w-${f.id}, ${(f.settings?.width || f.width) || 150}px)`).join(' '));
|
||||
container.style.setProperty('--dt-row-width', fields.reduce((sum, f) => sum + ((f.settings?.width || f.width) || 150), 0) + 'px');
|
||||
|
||||
// --- Editing Logic (Manual Control, Permanent DOM) ---
|
||||
let currentEditingNode = null;
|
||||
let leftSum = 0;
|
||||
fields.forEach(f => {
|
||||
const pinned = f.settings?.pinned || f.pinned;
|
||||
if (pinned === 'left') {
|
||||
container.style.setProperty(`--l-${f.id}`, leftSum + 'px');
|
||||
leftSum += ((f.settings?.width || f.width) || 150);
|
||||
}
|
||||
});
|
||||
let rightSum = 0;
|
||||
[...fields].reverse().forEach(f => {
|
||||
const pinned = f.settings?.pinned || f.pinned;
|
||||
if (pinned === 'right') {
|
||||
container.style.setProperty(`--r-${f.id}`, rightSum + 'px');
|
||||
rightSum += ((f.settings?.width || f.width) || 150);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
state.__watch('list', list => {
|
||||
if (state._fieldsDirty) {
|
||||
state._fieldsDirty = false;
|
||||
const fieldTemplate = container.querySelector('.dt-body template[index="rIdx"]')?.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;
|
||||
const pinned = f.settings?.pinned || f.pinned;
|
||||
if (pinned) {
|
||||
clone.classList.add('pinned-' + pinned);
|
||||
clone.style.position = 'sticky';
|
||||
clone.style.zIndex = '1';
|
||||
clone.style.backgroundColor = 'inherit';
|
||||
if (pinned === 'left') {
|
||||
clone.style.left = `var(--l-${f.id})`;
|
||||
clone.style.borderRight = '1px solid var(--bs-border-color)';
|
||||
clone.style.boxShadow = '2px 0 5px -2px rgba(0,0,0,0.1)';
|
||||
} else {
|
||||
clone.style.right = `var(--r-${f.id})`;
|
||||
clone.style.borderLeft = '1px solid var(--bs-border-color)';
|
||||
clone.style.boxShadow = '-2px 0 5px -2px rgba(0,0,0,0.1)';
|
||||
}
|
||||
}
|
||||
}
|
||||
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');
|
||||
const editor = overlay.querySelector('AutoForm');
|
||||
const rect = cellNode.getBoundingClientRect();
|
||||
const rootRect = container.getBoundingClientRect();
|
||||
|
||||
const overlay = container.querySelector('.dt-editor-overlay'), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
|
||||
currentEditingNode = cellNode;
|
||||
|
||||
// 1. Precise Positioning with expansion and Z-index
|
||||
let minW = rect.width;
|
||||
if (field.type === 'textarea' || field.type === 'TagsInput') minW = Math.max(rect.width, 300);
|
||||
else if (field.type === 'radio') minW = Math.max(rect.width, 240);
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
overlay.style.left = (rect.left - rootRect.left) + 'px';
|
||||
overlay.style.top = (rect.top - rootRect.top) + 'px';
|
||||
overlay.style.width = minW + 'px';
|
||||
|
||||
// Reset styles to prevent crosstalk from previous editor types
|
||||
overlay.style.height = '';
|
||||
overlay.style.minHeight = '';
|
||||
overlay.style.alignItems = 'center';
|
||||
|
||||
if (field.type === 'textarea' || field.type === 'TagsInput') {
|
||||
overlay.style.minHeight = (field.type === 'textarea' ? 150 : rect.height) + 'px';
|
||||
overlay.style.height = 'auto';
|
||||
overlay.style.alignItems = 'flex-start';
|
||||
} else {
|
||||
overlay.style.height = (rect.height) + 'px';
|
||||
const formType = field.settings?.formType || field.type || 'text';
|
||||
const form = overlay.querySelector('AutoForm');
|
||||
if (form) {
|
||||
// row 已经是 Proxy (NewState),直接赋值即可实现双向同步
|
||||
form.data = row;
|
||||
form.state.schema = [{ ...field, type: formType, options: field.settings?.options || field.options, name: field.id, label: '' }];
|
||||
RefreshState(form);
|
||||
}
|
||||
|
||||
// 2. Direct Driver: Switch context
|
||||
// Use global State for both schema and data to ensure atomic updates
|
||||
State.editingSchema = [{ ...field, name: field.id, label: '' }];
|
||||
State.editingData = row;
|
||||
RefreshState(overlay);
|
||||
|
||||
// 3. Focus
|
||||
setTimeout(() => {
|
||||
const input = editor.querySelector('.form-control, .form-select, .form-check-input, input');
|
||||
if (input) input.focus();
|
||||
}, 30);
|
||||
Object.assign(overlay.style, {
|
||||
display: 'flex', left: (rect.left - rootRect.left) + 'px', top: (rect.top - rootRect.top) + 'px',
|
||||
width: (formType === 'textarea' || formType === 'TagsInput' ? Math.max(rect.width, 300) : rect.width) + 'px',
|
||||
height: (formType === 'textarea' || formType === 'TagsInput' ? 'auto' : rect.height + 'px')
|
||||
});
|
||||
setTimeout(() => overlay.querySelector('input, textarea, select, .form-control')?.focus(), 30);
|
||||
};
|
||||
|
||||
container.hideEditor = (save = true) => {
|
||||
if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay');
|
||||
if (!_editorOverlay || _editorOverlay.style.display === 'none') return;
|
||||
const form = _editorOverlay.querySelector('AutoForm');
|
||||
if (save && form && form.data) {
|
||||
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 }));
|
||||
RefreshState(form);
|
||||
|
||||
_editorOverlay.style.display = 'none';
|
||||
if (save && currentEditingNode) {
|
||||
RefreshState(currentEditingNode);
|
||||
// 手动同步数据回原始行 (解决 Proxy 隔离问题)
|
||||
const schema = form.state.schema || [];
|
||||
schema.forEach(field => {
|
||||
const row = currentEditingNode?.closest('.dt-row')?._ref?.item;
|
||||
if (row) row[field.name] = form.data[field.name];
|
||||
});
|
||||
|
||||
if (state.isBulkEdit) {
|
||||
const { minRow, maxRow, fIdx } = state.isBulkEdit;
|
||||
const field = state.fields[fIdx];
|
||||
const newValue = form.data[field.id];
|
||||
for (let i = minRow; i <= maxRow; i++) {
|
||||
if (state.list[i]) {
|
||||
state.list[i][field.id] = newValue;
|
||||
}
|
||||
State.editingSchema = null;
|
||||
State.editingData = null;
|
||||
}
|
||||
state.list = [...state.list];
|
||||
}
|
||||
|
||||
if (currentEditingNode) RefreshState(currentEditingNode);
|
||||
state.isDirty = true;
|
||||
}
|
||||
_editorOverlay.style.display = 'none';
|
||||
if (form) { form.state.schema = []; form.data = null; }
|
||||
currentEditingNode = null;
|
||||
state.isBulkEdit = null;
|
||||
container.focus();
|
||||
};
|
||||
|
||||
// --- Shared Logic ---
|
||||
container.startSelect = selection.startSelect;
|
||||
container.updateSelect = selection.updateSelect;
|
||||
container.deleteSelected = selection.deleteSelected;
|
||||
container.onMainMouseDown = e => {
|
||||
const cell = e.target.closest('.dt-cell'), row = 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 = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
||||
selection.startSelect(rIdx + state._listStartIndex, fIdx, e);
|
||||
};
|
||||
|
||||
container.addEventListener('keydown', e => {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
const k = e.key.toLowerCase();
|
||||
if (k === 'c') {
|
||||
e.preventDefault();
|
||||
selection.copy();
|
||||
}
|
||||
if (k === 'v') {
|
||||
e.preventDefault();
|
||||
selection.paste();
|
||||
container.onMainMouseOver = e => {
|
||||
if (state.isSelecting) {
|
||||
const cell = e.target.closest('.dt-cell'), row = 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 = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
||||
selection.updateSelect(rIdx + state._listStartIndex, fIdx);
|
||||
}
|
||||
}
|
||||
if (e.key === 'Escape') container.hideEditor(false);
|
||||
};
|
||||
|
||||
container.onMainDblClick = e => {
|
||||
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
|
||||
if (row && !row.classList.contains('dt-header-row')) {
|
||||
const item = row._ref?.item, fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
|
||||
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
||||
const absoluteRow = rIdx + state._listStartIndex;
|
||||
|
||||
if (item && state.fields[fIdx]) {
|
||||
const bounds = selection.getSelectionBounds();
|
||||
if (bounds && absoluteRow >= bounds.minRow && absoluteRow <= bounds.maxRow && fIdx >= bounds.minCol && fIdx <= bounds.maxCol) {
|
||||
const affectedRows = bounds.maxRow - bounds.minRow + 1;
|
||||
if (affectedRows > 1) {
|
||||
state.isBulkEdit = { ...bounds, fIdx };
|
||||
if (globalThis.UI?.toast) UI.toast(`Bulk Edit: Updating ${affectedRows} rows in column "${state.fields[fIdx].name}"`, { type: 'warning' });
|
||||
}
|
||||
}
|
||||
container.editCell(item, state.fields[fIdx], cell);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
container.addRow = () => {
|
||||
const newRow = {};
|
||||
state.fields.forEach(f => newRow[f.id] = '');
|
||||
state._originalList.push(newRow);
|
||||
state.list = [...state._originalList]; // Apply to current view too
|
||||
state.isDirty = true;
|
||||
setTimeout(() => {
|
||||
scroll.reset(state.list);
|
||||
container.querySelector('.dt-main').scrollTop = container.querySelector('.dt-main').scrollHeight;
|
||||
}, 50);
|
||||
};
|
||||
|
||||
container.deleteSelectedRow = async () => {
|
||||
const bounds = selection.getSelectionBounds();
|
||||
if (!bounds) return;
|
||||
const count = bounds.maxRow - bounds.minRow + 1;
|
||||
if (await UI.confirm(`Are you sure you want to delete ${count} row(s)?`)) {
|
||||
const rMin = bounds.minRow, rMax = bounds.maxRow;
|
||||
const removedItems = state.list.slice(rMin, rMax + 1);
|
||||
state.list = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
|
||||
state._originalList = state._originalList.filter(item => !removedItems.includes(item));
|
||||
state.isDirty = true;
|
||||
selection.clearAllActive();
|
||||
container.dispatchEvent(new CustomEvent('remove', { detail: { items: removedItems } }));
|
||||
}
|
||||
};
|
||||
|
||||
container.saveChanges = () => {
|
||||
container.dispatchEvent(new CustomEvent('save', { detail: { list: state._originalList, fields: state.fields } }));
|
||||
state.isDirty = false;
|
||||
};
|
||||
|
||||
const getFieldSchema = () => {
|
||||
const types = DataTable.getFieldTypes();
|
||||
const baseSchema = [
|
||||
{ name: 'id', label: 'Field ID', type: 'text', setting: { required: true, placeholder: 'e.g. user_name' } },
|
||||
{ name: 'name', label: 'Display Name', type: 'text', setting: { required: true, placeholder: 'e.g. 用户名' } },
|
||||
{ name: 'user_type', label: 'Field Type', type: 'select', options: types.map(t => ({ label: t.label, value: t.value })) }
|
||||
];
|
||||
const dynamicSchema = types.reduce((acc, t) => acc.concat(t.schema || []), []);
|
||||
return baseSchema.concat(dynamicSchema, [
|
||||
{ name: 'isIndex', label: 'Index', type: 'switch' },
|
||||
{ name: 'memo', label: 'Memo', type: 'text' }
|
||||
]);
|
||||
};
|
||||
|
||||
const parseOptionsStr = (str) => {
|
||||
if (!str) return undefined;
|
||||
return str.split('\n').map(s => s.trim()).filter(Boolean).map(line => {
|
||||
const idx = line.indexOf(':');
|
||||
if (idx > -1) return { label: line.slice(0, idx).trim(), value: line.slice(idx + 1).trim() };
|
||||
return line;
|
||||
});
|
||||
};
|
||||
const formatOptionsStr = (opts) => {
|
||||
if (!opts) return '';
|
||||
return opts.map(o => typeof o === 'object' ? `${o.label}:${o.value}` : o).join('\n');
|
||||
};
|
||||
|
||||
container.addField = async () => {
|
||||
container.hideColumnMenu();
|
||||
const data = NewState({ id: 'c' + Date.now().toString().slice(-4), name: 'New Field', user_type: 'text', decimals: 0, isIndex: false, memo: '', options_str: '' });
|
||||
|
||||
const d = document.body.appendChild(document.createElement('Dialog'));
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
Object.assign(d.state, { title: 'Add Field', buttons: ['Cancel', 'Save'] });
|
||||
RefreshState(d);
|
||||
|
||||
const body = d.querySelector('.modal-body');
|
||||
const form = body.appendChild(document.createElement('AutoForm'));
|
||||
form.setAttribute('nobutton', '');
|
||||
RefreshState(form);
|
||||
form.data = data;
|
||||
form.state.schema = getFieldSchema();
|
||||
|
||||
form.addEventListener('change', (e) => e.stopPropagation());
|
||||
|
||||
d.show();
|
||||
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result)));
|
||||
if (result === 2) {
|
||||
const typeInfo = DataTable._fieldTypes.get(data.user_type);
|
||||
let dbType = typeInfo?.typeForDB || 'v1024';
|
||||
if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
|
||||
|
||||
const field = {
|
||||
id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex,
|
||||
type: dbType,
|
||||
settings: {
|
||||
formType: data.user_type,
|
||||
decimals: data.decimals,
|
||||
prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep,
|
||||
labelOn: data.labelOn, labelOff: data.labelOff, format: data.format,
|
||||
placeholder: data.placeholder,
|
||||
options: parseOptionsStr(data.options_str)
|
||||
}
|
||||
};
|
||||
state.fields = [...state.fields, field];
|
||||
state.isDirty = true;
|
||||
container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields }));
|
||||
state.list = [...state.list];
|
||||
}
|
||||
d.remove();
|
||||
};
|
||||
|
||||
container.editField = async () => {
|
||||
if (!state.activeField) return;
|
||||
container.hideColumnMenu();
|
||||
const f = state.activeField;
|
||||
const s = f.settings || {};
|
||||
const data = NewState({
|
||||
id: f.id, name: f.name, memo: f.memo || '', isIndex: !!f.isIndex,
|
||||
user_type: s.formType || 'text',
|
||||
decimals: s.decimals || 0,
|
||||
prefix: s.prefix || '', suffix: s.suffix || '', thousandSep: !!s.thousandSep,
|
||||
labelOn: s.labelOn || '', labelOff: s.labelOff || '', format: s.format || '',
|
||||
placeholder: s.placeholder || '',
|
||||
options_str: formatOptionsStr(s.options)
|
||||
});
|
||||
|
||||
const onGlobalMouseDown = e => {
|
||||
const overlay = container.querySelector('.dt-editor-overlay');
|
||||
if (overlay && overlay.style.display !== 'none' && !overlay.contains(e.target)) {
|
||||
container.hideEditor(true);
|
||||
const d = document.body.appendChild(document.createElement('Dialog'));
|
||||
await new Promise(r => setTimeout(r, 0));
|
||||
Object.assign(d.state, { title: 'Edit Field', buttons: ['Cancel', 'Save'] });
|
||||
RefreshState(d);
|
||||
|
||||
const body = d.querySelector('.modal-body');
|
||||
const form = body.appendChild(document.createElement('AutoForm'));
|
||||
form.setAttribute('nobutton', '');
|
||||
RefreshState(form);
|
||||
form.data = data;
|
||||
form.state.schema = getFieldSchema();
|
||||
|
||||
form.addEventListener('change', (e) => e.stopPropagation());
|
||||
|
||||
d.show();
|
||||
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result)));
|
||||
if (result === 2) {
|
||||
const idx = state.fields.findIndex(item => item.id === f.id);
|
||||
if (idx !== -1) {
|
||||
const typeInfo = DataTable._fieldTypes.get(data.user_type);
|
||||
let dbType = typeInfo?.typeForDB || 'v1024';
|
||||
if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
|
||||
|
||||
const updatedField = {
|
||||
...f, id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex,
|
||||
type: dbType,
|
||||
settings: {
|
||||
...f.settings,
|
||||
formType: data.user_type,
|
||||
decimals: data.decimals,
|
||||
prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep,
|
||||
labelOn: data.labelOn, labelOff: data.labelOff, format: data.format,
|
||||
placeholder: data.placeholder,
|
||||
options: parseOptionsStr(data.options_str)
|
||||
}
|
||||
if (!container.contains(e.target) && !overlay?.contains(e.target)) selection.clearAllActive();
|
||||
};
|
||||
state.fields[idx] = updatedField;
|
||||
state.fields = [...state.fields];
|
||||
state.isDirty = true;
|
||||
container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields }));
|
||||
state.list = [...state.list];
|
||||
}
|
||||
}
|
||||
d.remove();
|
||||
};
|
||||
|
||||
container.deleteField = async () => {
|
||||
if (!state.activeField) return;
|
||||
container.hideColumnMenu();
|
||||
if (await UI.confirm(`Are you sure you want to delete field "${state.activeField.name}"?`)) {
|
||||
const idx = state.fields.findIndex(f => f.id === state.activeField.id);
|
||||
if (idx !== -1) {
|
||||
state.fields.splice(idx, 1);
|
||||
state.fields = [...state.fields];
|
||||
state.isDirty = true;
|
||||
container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields }));
|
||||
state.list = [...state.list]; // Trigger body refresh
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('mouseup', selection.endSelect);
|
||||
document.addEventListener('mousedown', onGlobalMouseDown);
|
||||
document.addEventListener('mousedown', e => {
|
||||
const overlay = container.querySelector('.dt-editor-overlay');
|
||||
const menu = container.querySelector('.dt-column-menu');
|
||||
if (overlay?.style.display !== 'none' && !overlay.contains(e.target)) container.hideEditor(true);
|
||||
if (!container.contains(e.target) && !overlay?.contains(e.target) && !menu?.contains(e.target)) selection.clearAllActive();
|
||||
});
|
||||
|
||||
container._onUnload = () => {
|
||||
document.removeEventListener('mousedown', onGlobalMouseDown)
|
||||
window.removeEventListener('mouseup', selection.endSelect)
|
||||
}
|
||||
// Exposure for templates
|
||||
state._MODE_ICONS = MODE_ICONS;
|
||||
|
||||
// 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: \${ }
|
||||
}, Util.makeDom(/*html*/`
|
||||
<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" onunload="this._onUnload()" tabindex="0">
|
||||
<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)"
|
||||
$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-row dt-header-row fw-bold text-muted small">
|
||||
<div $each="this.state?.fields || []" class="dt-cell border-end px-2 d-flex align-items-center">
|
||||
<span $text="item.name" class="text-truncate"></span>
|
||||
<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.settings?.pinned || item.pinned) ? 'pinned-' + (item.settings?.pinned || item.pinned) : ''}" $style="((item.settings?.pinned || item.pinned) ? 'position: sticky; z-index: 11; background-color: inherit; ' : 'position:relative; ') + 'padding: 0; ' + ((item.settings?.pinned || 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.settings?.pinned || 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>
|
||||
<div $each="this.state?._renderedList || []" key="id" index="rIdx" class="dt-row dt-body-row border-bottom bg-white">
|
||||
<div $each="this.state?.fields || []" as="f" index="fIdx" class="dt-cell border-end px-2 d-flex align-items-center">
|
||||
<span $text="this.format(item[f.id], f)" class="text-truncate"></span>
|
||||
</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.settings?.pinned || f.pinned) ? 'pinned-' + (f.settings?.pinned || f.pinned) : ''}" $style="((f.settings?.pinned || f.pinned) ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + ((f.settings?.pinned || 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.settings?.pinned || 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>
|
||||
</div>
|
||||
</template>
|
||||
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shared Editor Overlay -->
|
||||
<div class="dt-editor-overlay" style="display: none" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
||||
<AutoForm inline class="h-100 w-100" $onsubmit="thisNode.closest('DataTable').hideEditor(true)"/>
|
||||
<div class="dt-column-menu bg-body shadow-lg rounded p-2" style="display:none; position:absolute; z-index:2000; min-width:240px; max-width:300px; border: 1px solid var(--bs-primary)">
|
||||
<template $if="this.state?.activeFieldId">
|
||||
<div class="d-flex gap-1 mb-2">
|
||||
<button $class="btn btn-xs flex-grow-1 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-xs flex-grow-1 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 class="dt-footer border-top bg-light d-flex align-items-center px-2 py-1 small text-muted" style="height:32px">
|
||||
<div class="flex-grow-1">
|
||||
Total: <span $text="this.state?.list?.length || 0"></span> |
|
||||
Selected Rows: <span $text="this.state?.selectedRowCount || 0"></span>
|
||||
<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-2" style="max-height: 180px; overflow-y: auto;">
|
||||
<div class="text-muted fw-bold mb-1" style="font-size: 9px; 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-2 pt-1 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>
|
||||
|
||||
<div class="mt-3 pt-2 border-top d-flex flex-column gap-1">
|
||||
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.editField()"><i class="bi bi-pencil me-2"></i> Edit Field</button>
|
||||
<button class="btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick="this.addField()"><i class="bi bi-plus-lg me-2"></i> Add Field</button>
|
||||
<button class="btn btn-xs btn-outline-danger border d-flex align-items-center px-2 py-1" $onclick="this.deleteField()"><i class="bi bi-trash me-2"></i> Delete Field</button>
|
||||
</div>
|
||||
</template>
|
||||
</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="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"/></div>
|
||||
|
||||
<div class="dt-footer border-top bg-light d-flex align-items-center px-3 py-1 shadow-sm" style="height:40px; z-index: 10">
|
||||
<div class="d-flex align-items-center gap-3 flex-grow-1">
|
||||
<div class="btn-group shadow-sm">
|
||||
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.addRow()" title="Add Row"><i class="bi bi-plus-lg text-primary me-1"></i> Add</button>
|
||||
<button class="btn btn-xs btn-white border d-flex align-items-center px-2" style="background:white" $onclick="this.deleteSelectedRow()" $disabled="!this.state?.selectedRowCount" title="Delete Selected Rows"><i class="bi bi-trash text-danger me-1"></i> Delete</button>
|
||||
</div>
|
||||
<div class="vr h-50 my-auto text-muted opacity-25"></div>
|
||||
<div class="d-flex align-items-center gap-2 text-muted" style="font-size: 0.75rem">
|
||||
<i class="bi bi-check-all fs-6"></i>
|
||||
<span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button $if="this.state?.isDirty" class="btn btn-xs btn-primary px-3 shadow-sm d-flex align-items-center fw-bold" $onclick="this.saveChanges()"><i class="bi bi-cloud-upload me-1"></i> Save Changes</button>
|
||||
<button $if="!this.state?.isDirty" class="btn btn-xs btn-light border px-3 text-muted disabled d-flex align-items-center" disabled><i class="bi bi-cloud-check me-1"></i> Up to date</button>
|
||||
</div>
|
||||
<button class="btn btn-xs btn-outline-danger py-0 px-2" style="font-size: 11px" $disabled="!this.state?.selectedRowCount" $onclick="this.deleteSelected()">
|
||||
<i class="bi bi-trash me-1"></i>Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`), Util.makeDom(/*html*/`
|
||||
<style>
|
||||
DataTable { display: block; }
|
||||
.dt-root { font-size: 0.875rem; }
|
||||
.dt-row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--dt-grid-template);
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
height: 40px;
|
||||
}
|
||||
.dt-header-row {
|
||||
background-color: var(--bs-tertiary-bg);
|
||||
}
|
||||
.dt-cell {
|
||||
background: inherit;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.dt-body-row:hover {
|
||||
background-color: var(--bs-secondary-bg) !important;
|
||||
}
|
||||
.dt-cell-selected {
|
||||
background-color: var(--bs-primary-bg-subtle) !important;
|
||||
}
|
||||
.dt-editor-overlay {
|
||||
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);
|
||||
align-items: center;
|
||||
overflow: visible;
|
||||
transition: top 0.1s ease, left 0.1s ease, width 0.1s ease, height 0.1s ease, min-height 0.1s ease;
|
||||
}
|
||||
.dt-editor-overlay .auto-form-root, .dt-editor-overlay form {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.dt-editor-overlay .form-control, .dt-editor-overlay .form-select {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 8px !important;
|
||||
}
|
||||
.dt-editor-overlay .form-check.form-switch {
|
||||
padding-left: 2.5em;
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.btn-xs {
|
||||
padding: 1px 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.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>
|
||||
`))
|
||||
|
||||
if (typeof document !== 'undefined') RefreshState(document.documentElement)
|
||||
|
||||
@ -4,12 +4,13 @@ export const createScrollManager = (container, state, onRenderedListChange) => {
|
||||
const vs = VirtualScroll({ itemHeight: 40 });
|
||||
let scrollEl = null;
|
||||
|
||||
const refresh = () => {
|
||||
const refresh = (isLayoutChange = false) => {
|
||||
if (!scrollEl) return;
|
||||
const res = vs.calc(scrollEl, state.list);
|
||||
if (res) {
|
||||
// Dirty check: Abort if layout boundaries haven't actually changed
|
||||
if (state.prevHeight === res.prevHeight &&
|
||||
if (!isLayoutChange &&
|
||||
state.prevHeight === res.prevHeight &&
|
||||
state.postHeight === res.postHeight &&
|
||||
state._listStartIndex === res.listStartIndex &&
|
||||
state._renderedList.length === res.renderedList.length) {
|
||||
@ -22,7 +23,7 @@ export const createScrollManager = (container, state, onRenderedListChange) => {
|
||||
_listStartIndex: res.listStartIndex,
|
||||
_renderedList: res.renderedList
|
||||
});
|
||||
onRenderedListChange?.(res.renderedList.length);
|
||||
onRenderedListChange?.(res.renderedList.length, isLayoutChange);
|
||||
}
|
||||
};
|
||||
|
||||
@ -32,12 +33,14 @@ export const createScrollManager = (container, state, onRenderedListChange) => {
|
||||
},
|
||||
reset: (list) => {
|
||||
state._listStartIndex = 0;
|
||||
state._renderedList = vs.reset(list, scrollEl || container) || [];
|
||||
// Optimization: Don't update _renderedList here.
|
||||
// vs.init will trigger refresh(true) which does it properly once.
|
||||
vs.reset(list, scrollEl || container);
|
||||
if (state.list === list) {
|
||||
vs.init(list, refresh);
|
||||
vs.init(list, () => refresh(true));
|
||||
}
|
||||
},
|
||||
refresh,
|
||||
onScroll: refresh
|
||||
onScroll: () => refresh(false)
|
||||
};
|
||||
};
|
||||
|
||||
@ -11,6 +11,9 @@ export const createSelectionManager = (container, state) => {
|
||||
let lastHadSelection = false;
|
||||
|
||||
const applySelectionUI = () => {
|
||||
/* DEBUG: Performance Isolation Toggle */
|
||||
if (window.__DT_FEATURES__ && !window.__DT_FEATURES__.selection) return;
|
||||
|
||||
let boundMinRow = Infinity, boundMaxRow = -Infinity;
|
||||
if (activeBounds) {
|
||||
boundMinRow = Math.min(boundMinRow, activeBounds.minRow);
|
||||
@ -22,43 +25,30 @@ export const createSelectionManager = (container, state) => {
|
||||
});
|
||||
|
||||
const hasSelection = boundMinRow !== Infinity;
|
||||
|
||||
// Fast path: If no current selection AND last frame also had no selection, skip EVERYTHING.
|
||||
if (!hasSelection && !lastHadSelection) return;
|
||||
lastHadSelection = hasSelection;
|
||||
|
||||
const body = container.querySelector('.dt-body');
|
||||
if (!body) return;
|
||||
const rowNodes = body.children;
|
||||
|
||||
// Fast path for scroll performance: Only iterate if selection overlaps or we need to clear
|
||||
for (let r = 0; r < rowNodes.length; r++) {
|
||||
const rowNode = rowNodes[r];
|
||||
if (!rowNode.classList.contains('dt-body-row')) continue;
|
||||
|
||||
const rowNodes = body.querySelectorAll('.dt-body-row');
|
||||
rowNodes.forEach(rowNode => {
|
||||
const absoluteRow = (rowNode._ref?.rIdx ?? -1) + state._listStartIndex;
|
||||
const cellNodes = rowNode.children;
|
||||
const cells = rowNode.querySelectorAll('.dt-cell');
|
||||
|
||||
// Fast path: Row completely outside selection
|
||||
if (!hasSelection || absoluteRow < boundMinRow || absoluteRow > boundMaxRow) {
|
||||
for (let i = 0; i < cellNodes.length; i++) {
|
||||
if (cellNodes[i].classList.contains('dt-cell-selected')) {
|
||||
cellNodes[i].classList.remove('dt-cell-selected');
|
||||
}
|
||||
}
|
||||
continue;
|
||||
cells.forEach(cell => cell.classList.remove('dt-cell-selected'));
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < cellNodes.length; i++) {
|
||||
const isSelected = isCellSelected(absoluteRow, i);
|
||||
const hasClass = cellNodes[i].classList.contains('dt-cell-selected');
|
||||
if (isSelected && !hasClass) {
|
||||
cellNodes[i].classList.add('dt-cell-selected');
|
||||
} else if (!isSelected && hasClass) {
|
||||
cellNodes[i].classList.remove('dt-cell-selected');
|
||||
}
|
||||
}
|
||||
cells.forEach((cell, cIdx) => {
|
||||
if (isCellSelected(absoluteRow, cIdx)) {
|
||||
cell.classList.add('dt-cell-selected');
|
||||
} else {
|
||||
cell.classList.remove('dt-cell-selected');
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const updateStatus = () => {
|
||||
|
||||
@ -10,7 +10,10 @@ test('DataTable comprehensive tests and scrolling benchmarks', async ({ page })
|
||||
|
||||
// 1. 遍历验证第一行每个单元格的编辑与数据同步
|
||||
await page.waitForSelector('.dt-row');
|
||||
const fields = await page.evaluate(() => document.querySelector('DataTable').state.fields.map(f => ({ id: f.id, type: f.type })));
|
||||
const fields = await page.evaluate(() => document.querySelector('DataTable').state.fields.map(f => ({
|
||||
id: f.id,
|
||||
type: f.settings?.formType || f.type
|
||||
})));
|
||||
console.log('Fields to test:', fields.map(f => f.id).join(', '));
|
||||
|
||||
for (let i = 0; i < fields.length; i++) {
|
||||
@ -51,6 +54,7 @@ test('DataTable comprehensive tests and scrolling benchmarks', async ({ page })
|
||||
await page.fill(`${editorSelector} textarea`, newValue);
|
||||
} else if (field.type === 'TagsInput') {
|
||||
newValue = ['Playwright'];
|
||||
await page.click(`${editorSelector} input`);
|
||||
await page.fill(`${editorSelector} input`, 'Playwright');
|
||||
await page.keyboard.press('Enter');
|
||||
} else if (field.type === 'number') {
|
||||
@ -64,13 +68,13 @@ test('DataTable comprehensive tests and scrolling benchmarks', async ({ page })
|
||||
await page.fill(`${editorSelector} input`, newValue);
|
||||
}
|
||||
|
||||
// 退出编辑模式
|
||||
// 退出编辑模式 (保存)
|
||||
await page.evaluate(() => {
|
||||
const table = document.querySelector('DataTable');
|
||||
const row = table.state.list[0];
|
||||
row._editingF = null;
|
||||
table.hideEditor(true);
|
||||
});
|
||||
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// 验证视图层 (DOM) 是否同步渲染
|
||||
@ -83,7 +87,17 @@ test('DataTable comprehensive tests and scrolling benchmarks', async ({ page })
|
||||
}, colIdx);
|
||||
console.log(`Column [${field.id}] UI text:`, cellText);
|
||||
|
||||
if (field.type !== 'switch' && field.id !== 'active') {
|
||||
if (field.id === 'score') {
|
||||
expect(cellText).toContain('$99.00'); // 99 + formatting
|
||||
} else if (field.id === 'role') {
|
||||
expect(cellText).toContain('Content Editor'); // Editor mapped to label
|
||||
} else if (field.id === 'active') {
|
||||
expect(cellText).toContain('NO'); // false mapped to labelOff
|
||||
} else if (field.id === 'tags') {
|
||||
expect(cellText).toContain('State.js | Playwright'); // Formatter logic
|
||||
} else if (field.id === 'actions') {
|
||||
expect(cellText).toContain('🔧 Edit'); // Custom formatter
|
||||
} else {
|
||||
expect(cellText).toContain(String(newValue).split('\n')[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@
|
||||
if (!form.state) form.state = NewState({ schema: [] });
|
||||
|
||||
console.log('--- STEP 1: TEXT ---');
|
||||
DiagnosticState.schema = [{ id: 't1', name: 't1', label: '', type: 'text' }];
|
||||
DiagnosticState.schema = [{ name: 't1', label: '', type: 'text' }];
|
||||
DiagnosticState.data = { t1: 'Hello World' };
|
||||
form.state.schema = DiagnosticState.schema;
|
||||
form.data = DiagnosticState.data;
|
||||
@ -36,7 +36,7 @@
|
||||
console.log('Input value:', form.querySelector('input')?.value);
|
||||
|
||||
console.log('--- STEP 2: TAGS ---');
|
||||
DiagnosticState.schema = [{ id: 'tags', name: 'tags', label: '', type: 'TagsInput' }];
|
||||
DiagnosticState.schema = [{ name: 'tags', label: '', type: 'TagsInput' }];
|
||||
DiagnosticState.data = { tags: ['A', 'B'] };
|
||||
form.state.schema = DiagnosticState.schema;
|
||||
form.data = DiagnosticState.data;
|
||||
@ -45,7 +45,7 @@
|
||||
console.log('Tags count:', form.querySelectorAll('TagsInput button').length);
|
||||
|
||||
console.log('--- STEP 3: RADIO ---');
|
||||
DiagnosticState.schema = [{ id: 'r1', name: 'r1', label: '', type: 'radio', options: ['Op1', 'Op2'] }];
|
||||
DiagnosticState.schema = [{ name: 'r1', label: '', type: 'radio', options: ['Op1', 'Op2'] }];
|
||||
DiagnosticState.data = { r1: 'Op2' };
|
||||
form.state.schema = DiagnosticState.schema;
|
||||
form.data = DiagnosticState.data;
|
||||
|
||||
@ -1,103 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>AutoForm Comprehensive Diagnostic</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"@web/state": "../../state/src/index.js",
|
||||
"@web/base": "../../base/src/index.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import { Component, NewState, RefreshState } from '@web/state';
|
||||
import '@web/base';
|
||||
window.RefreshState = RefreshState;
|
||||
window.NewState = NewState;
|
||||
|
||||
window.DiagnosticState = {
|
||||
data: NewState({
|
||||
text: 'initial',
|
||||
tags: ['A', 'B'],
|
||||
area: 'long text',
|
||||
sel: 'v1'
|
||||
})
|
||||
};
|
||||
|
||||
const log = (msg, color = 'black') => {
|
||||
const pre = document.getElementById('log');
|
||||
const div = document.createElement('div');
|
||||
div.style.color = color;
|
||||
div.textContent = msg;
|
||||
pre.appendChild(div);
|
||||
console.log(msg);
|
||||
};
|
||||
|
||||
window.runFullDiagnostic = async () => {
|
||||
document.getElementById('log').innerHTML = '';
|
||||
const form = document.getElementById('diagForm');
|
||||
if (!form.state) form.state = NewState({ schema: [] });
|
||||
|
||||
const assert = (condition, msg) => {
|
||||
if (condition) log('✅ ' + msg, 'green');
|
||||
else log('❌ ' + msg, 'red');
|
||||
};
|
||||
|
||||
log('--- STEP 1: Text Input ---');
|
||||
form.state.schema = [{ id: 'text', name: 'text', label: '', type: 'text' }];
|
||||
form.data = DiagnosticState.data;
|
||||
RefreshState(form);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
assert(form.querySelector('input[type="text"]') !== null, 'Text input rendered');
|
||||
assert(form.querySelector('input[type="text"]').value === 'initial', 'Text value correct');
|
||||
|
||||
log('--- STEP 2: Switch to TextArea ---');
|
||||
form.state.schema = [{ id: 'area', name: 'area', label: '', type: 'textarea' }];
|
||||
RefreshState(form);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
assert(form.querySelector('textarea') !== null, 'TextArea rendered');
|
||||
assert(form.querySelector('input[type="text"]') === null || form.querySelector('input[type="text"]').offsetParent === null, 'Text input hidden/removed');
|
||||
assert(form.querySelector('textarea').value === 'long text', 'TextArea value correct');
|
||||
|
||||
log('--- STEP 3: Switch to TagsInput ---');
|
||||
form.state.schema = [{ id: 'tags', name: 'tags', label: '', type: 'TagsInput' }];
|
||||
RefreshState(form);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
assert(form.querySelector('TagsInput') !== null, 'TagsInput rendered');
|
||||
assert(form.querySelector('textarea') === null || form.querySelector('textarea').offsetParent === null, 'TextArea hidden/removed');
|
||||
const tagButtons = form.querySelectorAll('TagsInput button');
|
||||
assert(tagButtons.length === 2, 'Tags count correct: ' + tagButtons.length);
|
||||
assert(tagButtons[0].textContent === 'A', 'Tag A rendered');
|
||||
|
||||
log('--- STEP 4: Test Data Persistence ---');
|
||||
const input = form.querySelector('TagsInput input');
|
||||
input.value = 'C';
|
||||
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
assert(DiagnosticState.data.tags.includes('C'), 'Data saved to proxy: ' + DiagnosticState.data.tags);
|
||||
|
||||
log('--- STEP 5: Switch back to Text & Verify ---');
|
||||
form.state.schema = [{ id: 'text', name: 'text', label: '', type: 'text' }];
|
||||
RefreshState(form);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
const textInput = form.querySelector('input[type="text"]');
|
||||
textInput.value = 'modified';
|
||||
textInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
assert(DiagnosticState.data.text === 'modified', 'Text data saved: ' + DiagnosticState.data.text);
|
||||
|
||||
log('--- DIAGNOSTIC COMPLETE ---');
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body class="p-4">
|
||||
<h3>AutoForm Comprehensive Diagnostic</h3>
|
||||
<div style="width: 400px; border: 2px solid blue; min-height: 100px; padding: 10px;" class="bg-light">
|
||||
<AutoForm id="diagForm" inline></AutoForm>
|
||||
</div>
|
||||
<button class="btn btn-primary mt-3" onclick="runFullDiagnostic()">RUN COMPREHENSIVE DIAGNOSTIC</button>
|
||||
<div id="log" class="mt-4 p-3 border bg-dark text-light" style="font-family: monospace; white-space: pre-wrap; height: 300px; overflow-y: auto;"></div>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,23 +0,0 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('AutoForm comprehensive diagnostics', async ({ page }) => {
|
||||
page.on('console', msg => console.log('BROWSER:', msg.text()));
|
||||
await page.goto('/test/diagnostic_full.html');
|
||||
await page.click('button:has-text("RUN COMPREHENSIVE DIAGNOSTIC")');
|
||||
|
||||
// Wait for completion (look for the "DIAGNOSTIC COMPLETE" log entry)
|
||||
const log = page.locator('#log');
|
||||
await expect(log).toContainText('DIAGNOSTIC COMPLETE', { timeout: 10000 });
|
||||
|
||||
// Check for any red "❌" marks
|
||||
const failures = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('#log div'))
|
||||
.filter(div => div.textContent.includes('❌'))
|
||||
.map(div => div.textContent);
|
||||
});
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('Diagnostic Failures:', failures);
|
||||
}
|
||||
expect(failures).toHaveLength(0);
|
||||
});
|
||||
@ -6,6 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DataTable Demo</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<style>
|
||||
body, html { height: 100%; margin: 0; padding: 0; overflow: hidden; }
|
||||
@ -32,21 +33,28 @@
|
||||
<script type="module">
|
||||
import { RefreshState } from '@web/state'
|
||||
import '@web/base'
|
||||
import '../src/index.js'
|
||||
import { DataTable } from '../src/index.js'
|
||||
|
||||
// Externally register TagsInput
|
||||
DataTable.registerFieldType({
|
||||
value: 'TagsInput', label: '{#Tags#}', typeForDB: 'v4096',
|
||||
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="TagsInput"' }],
|
||||
formatter: (val) => Array.isArray(val) ? val.join(' | ') : val
|
||||
});
|
||||
|
||||
const table = document.getElementById('myTable')
|
||||
|
||||
const fields = [
|
||||
{ id: 'id', name: 'ID', width: 60, type: 'text', pinned: 'left' },
|
||||
{ id: 'name', name: 'User Name', width: 150, type: 'text', pinned: 'left' },
|
||||
{ id: 'role', name: 'Role', width: 120, type: 'select', options: ['Admin', 'Editor', 'Viewer'] },
|
||||
{ id: 'active', name: 'Active', width: 80, type: 'switch' },
|
||||
{ id: 'bio', name: 'Bio', width: 250, type: 'textarea' },
|
||||
{ id: 'gender', name: 'Gender', width: 120, type: 'radio', options: ['Male', 'Female', 'Other'] },
|
||||
{ id: 'tags', name: 'Tags', width: 150, type: 'TagsInput' },
|
||||
{ id: 'score', name: 'Score', width: 100, type: 'number' },
|
||||
{ id: 'created', name: 'Created At', width: 150, type: 'date' },
|
||||
{ id: 'actions', name: 'Actions', width: 100, type: 'text', pinned: 'right' }
|
||||
{ id: 'id', name: 'ID', type: 'string', settings: { width: 60, formType: 'text', pinned: 'left' } },
|
||||
{ id: 'name', name: 'User Name', type: 'string', settings: { width: 150, formType: 'text', pinned: 'left' } },
|
||||
{ id: 'role', name: 'Role', type: 'string', settings: { width: 120, formType: 'select', options: [{label:'Administrator', value:'Admin'}, {label:'Content Editor', value:'Editor'}, {label:'Viewer', value:'Viewer'}] } },
|
||||
{ id: 'active', name: 'Active', type: 'boolean', settings: { width: 80, formType: 'switch', labelOn: 'YES', labelOff: 'NO' } },
|
||||
{ id: 'bio', name: 'Bio', type: 'string', settings: { width: 250, formType: 'textarea' } },
|
||||
{ id: 'gender', name: 'Gender', type: 'string', settings: { width: 120, formType: 'radio', options: ['Male', 'Female', 'Other'] } },
|
||||
{ id: 'tags', name: 'Tags', type: 'object', settings: { width: 150, formType: 'TagsInput' } },
|
||||
{ id: 'score', name: 'Score', type: 'number', settings: { width: 100, formType: 'number', prefix: '$', decimals: 2, thousandSep: true } },
|
||||
{ id: 'created', name: 'Created At', type: 'date', settings: { width: 150, formType: 'date' } },
|
||||
{ id: 'actions', name: 'Actions', type: 'string', settings: { width: 100, formType: 'text', pinned: 'right' }, formatter: () => '🔧 Edit' }
|
||||
]
|
||||
|
||||
const generateData = (count) => Array.from({ length: count }, (_, i) => ({
|
||||
@ -57,7 +65,7 @@
|
||||
bio: 'Bio ' + (i + 1),
|
||||
gender: ['Male', 'Female', 'Other'][i % 3],
|
||||
tags: ['State.js', 'Vite'].slice(0, (i % 2) + 1),
|
||||
score: 80,
|
||||
score: 8000.5,
|
||||
created: '2026-05-17',
|
||||
actions: '...'
|
||||
}))
|
||||
@ -66,7 +74,8 @@
|
||||
|
||||
const init = () => {
|
||||
if (table.state) {
|
||||
Object.assign(table.state, { fields, list: data });
|
||||
table.state.fields = fields;
|
||||
table.state.list = data;
|
||||
RefreshState(table);
|
||||
window.testStatus = 'passed';
|
||||
} else {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user