Compare commits

..

9 Commits
v1.0.9 ... main

Author SHA1 Message Date
AI Engineer
bf745013b2 chore(datatable): 同步 package-lock 版本号至 1.0.15(by AI)
Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-06-22 19:33:51 +08:00
AI Engineer
52655e21cf fix(publish): publish.js 强制包名全小写以符合 npm 规范(by AI)
Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-06-22 19:29:12 +08:00
AI Engineer
e1aaeb57dd chore(datatable): 同步 publish 版本号至 1.0.15(by AI)
Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-06-22 19:28:06 +08:00
AI Engineer
d99b78fc6f fix(datatable): 修复 scope 从 @web 统一为 @apigo.cc,增强 editable 控制及编辑器交互(by AI)
Co-Authored-By: deepseek-v4-pro[1m] <deepseek-ai@claude-code-best.win>
2026-06-22 19:24:20 +08:00
AI Engineer
92b111c785 chore: release v1.0.14 with final UI polish, scroll-tracking editor and compact menus By: AICoder 2026-06-12 00:48:21 +08:00
AI Engineer
549ab0b552 fix: make inline editor overlay follow scrolling and refine menu markup By: AICoder 2026-06-12 00:14:59 +08:00
AI Engineer
ba7fccd6a7 fix: copy paste keydown listener and checkbox padding in inline editor By: AICoder 2026-06-11 23:20:08 +08:00
AI Engineer
fc835f5a10 fix: unhide option labels in inline editor By: AICoder 2026-06-11 22:51:18 +08:00
AI Engineer
ef40cf529b fix: responsive inline editor dimensions and modal field editor By: AICoder 2026-06-11 20:38:18 +08:00
17 changed files with 9791 additions and 178 deletions

View File

@ -1,6 +1,6 @@
# @web/datatable AI 开发指南 # @apigo.cc/datatable AI 开发指南
`@web/datatable` 是一个高性能、无状态的 Web 数据表格组件,支持万级数据虚拟滚动、多维编辑及固定列。 `@apigo.cc/datatable` 是一个高性能、无状态的 Web 数据表格组件,支持万级数据虚拟滚动、多维编辑及固定列。
--- ---
@ -11,9 +11,9 @@
<script type="importmap"> <script type="importmap">
{ {
"imports": { "imports": {
"@web/state": "path/to/state.mjs", "@apigo.cc/state": "path/to/state.mjs",
"@web/base": "path/to/base.mjs", "@apigo.cc/base": "path/to/base.mjs",
"@web/datatable": "path/to/datatable.mjs" "@apigo.cc/datatable": "path/to/datatable.mjs"
} }
} }
</script> </script>

134
dist/datatable.js vendored
View File

@ -276,14 +276,14 @@
}; };
const getSelectionBounds = () => { const getSelectionBounds = () => {
if (!activeBounds) return null; if (!activeBounds) return null;
let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow, minCol = activeBounds.minCol, maxCol = activeBounds.maxCol; let minRow2 = activeBounds.minRow, maxRow = activeBounds.maxRow, minCol2 = activeBounds.minCol, maxCol = activeBounds.maxCol;
multiSelections.forEach((s) => { multiSelections.forEach((s) => {
minRow = Math.min(minRow, s.minRow); minRow2 = Math.min(minRow2, s.minRow);
maxRow = Math.max(maxRow, s.maxRow); maxRow = Math.max(maxRow, s.maxRow);
minCol = Math.min(minCol, s.minCol); minCol2 = Math.min(minCol2, s.minCol);
maxCol = Math.max(maxCol, s.maxCol); maxCol = Math.max(maxCol, s.maxCol);
}); });
return { minRow, maxRow, minCol, maxCol }; return { minRow: minRow2, maxRow, minCol: minCol2, maxCol };
}; };
const copy = async () => { const copy = async () => {
const bounds = getSelectionBounds(); const bounds = getSelectionBounds();
@ -321,7 +321,11 @@
cells.push(current); cells.push(current);
return cells; return cells;
}); });
const { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds; let { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds;
if (minRow === maxRow && minCol === maxCol) {
maxRow = Infinity;
maxCol = Infinity;
}
const body = container.querySelector(".dt-body"); const body = container.querySelector(".dt-body");
const rowNodes = body ? Array.from(body.childNodes).filter((n) => { const rowNodes = body ? Array.from(body.childNodes).filter((n) => {
var _a; var _a;
@ -352,6 +356,7 @@
globalThis.Component.register("DataTable", (container) => { globalThis.Component.register("DataTable", (container) => {
if (!container.state) container.state = globalThis.NewState({}); if (!container.state) container.state = globalThis.NewState({});
const state = container.state; const state = container.state;
container.editable = container.hasAttribute("editable");
Object.assign(state, { Object.assign(state, {
list: [], list: [],
fields: [], fields: [],
@ -372,7 +377,8 @@
_fieldsDirty: false, _fieldsDirty: false,
_masterCellNodes: null, _masterCellNodes: null,
isDirty: false, isDirty: false,
isBulkEdit: null isBulkEdit: null,
editable: container.editable
}); });
const perf = createPerfMonitor(); const perf = createPerfMonitor();
state.perf = perf.stats; state.perf = perf.stats;
@ -400,6 +406,7 @@
perf.onScroll(); perf.onScroll();
scroll.refresh(); scroll.refresh();
container.hideColumnMenu(); container.hideColumnMenu();
container.hideEditor(true);
const prev = container.querySelector(".dt-spacer-prev"), post = container.querySelector(".dt-spacer-post"); const prev = container.querySelector(".dt-spacer-prev"), post = container.querySelector(".dt-spacer-post");
if (prev) { if (prev) {
prev.style.height = (state.prevHeight || 0) + "px"; prev.style.height = (state.prevHeight || 0) + "px";
@ -462,6 +469,11 @@
container.showColumnMenu = (field, event) => { container.showColumnMenu = (field, event) => {
var _a; var _a;
const btn = event.currentTarget, menu = container.querySelector(".dt-column-menu"); const btn = event.currentTarget, menu = container.querySelector(".dt-column-menu");
if (menu.style.display === "block" && state.activeFieldId === field.id) {
container.hideColumnMenu();
container.applySortFilter();
return;
}
const type = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text"; const type = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text";
state.activeModes = MODE_MAP[type] || (["boolean", "switch", "checkbox", "radio"].includes(type) ? [] : MODE_MAP.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: [] }; if (!state.filterConfig[field.id]) state.filterConfig[field.id] = { mode: state.activeModes[0] || "contains", value: "", selectedValues: [] };
@ -615,23 +627,31 @@
}); });
container.editCell = (row, field, cellNode) => { container.editCell = (row, field, cellNode) => {
var _a, _b; var _a, _b;
const overlay = container.querySelector(".dt-editor-overlay"), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect(); const main = container.querySelector(".dt-main");
const overlay = container.querySelector(".dt-editor-overlay"), rect = cellNode.getBoundingClientRect(), mainRect = main.getBoundingClientRect();
currentEditingNode = cellNode; currentEditingNode = cellNode;
const formType = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text"; const formType = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text";
const form = overlay.querySelector("AutoForm"); const form = overlay.querySelector("AutoForm");
if (form) { if (form) {
form.data = row; form.data = globalThis.NewState(globalThis.Util.clone(row));
form.state.schema = [{ ...field, type: formType, options: ((_b = field.settings) == null ? void 0 : _b.options) || field.options, name: field.id, label: "" }]; form.state.schema = [{ ...field, type: formType, options: ((_b = field.settings) == null ? void 0 : _b.options) || field.options, name: field.id, label: "" }];
} }
const isComplex = ["textarea", "TagsInput", "checkbox", "radio"].includes(formType); const isComplex = ["textarea", "TagsInput", "checkbox", "radio"].includes(formType);
const minWidth = isComplex ? 300 : 200; let topPos = rect.top - mainRect.top + main.scrollTop - 1;
let leftPos = rect.left - mainRect.left + main.scrollLeft - 1;
let editorWidth = Math.max(rect.width + 2, isComplex ? 300 : 0);
const maxLeft = main.scrollWidth - editorWidth - 5;
if (leftPos > maxLeft) leftPos = Math.max(0, maxLeft);
Object.assign(overlay.style, { Object.assign(overlay.style, {
display: "flex", display: "flex",
left: rect.left - rootRect.left + "px", left: leftPos + "px",
top: rect.top - rootRect.top + "px", top: topPos + "px",
width: Math.max(rect.width, minWidth) + "px", width: editorWidth + "px",
height: "auto", height: "auto",
minHeight: rect.height + "px" minHeight: rect.height + 2 + "px",
maxHeight: Math.max(100, mainRect.height - (rect.top - mainRect.top) - 5) + "px",
overflow: "auto",
padding: "0"
}); });
setTimeout(() => { setTimeout(() => {
var _a2; var _a2;
@ -645,23 +665,32 @@
if (save && form && form.data) { if (save && form && form.data) {
const input = _editorOverlay.querySelector("input:focus, select:focus, textarea:focus"); 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 (input) input.dispatchEvent(new Event(input.type === "number" || input.tagName === "SELECT" ? "change" : "input", { bubbles: true }));
let hasChanges = false;
const schema = form.state.schema || []; const schema = form.state.schema || [];
schema.forEach((field) => { schema.forEach((field) => {
var _a, _b; var _a, _b;
const row = (_b = (_a = currentEditingNode == null ? void 0 : currentEditingNode.closest(".dt-row")) == null ? void 0 : _a._ref) == null ? void 0 : _b.item; const row = (_b = (_a = currentEditingNode == null ? void 0 : currentEditingNode.closest(".dt-row")) == null ? void 0 : _a._ref) == null ? void 0 : _b.item;
if (row) row[field.name] = form.data[field.name]; if (row && JSON.stringify(row[field.name]) !== JSON.stringify(form.data[field.name])) {
row[field.name] = form.data[field.name];
hasChanges = true;
}
}); });
if (state.isBulkEdit) { if (state.isBulkEdit) {
const { minRow, maxRow, fIdx } = state.isBulkEdit; const { minRow: minRow2, maxRow, fIdx } = state.isBulkEdit;
const field = state.fields[fIdx]; const field = state.fields[fIdx];
const newValue = form.data[field.id]; const newValue = form.data[field.id];
for (let i = minRow; i <= maxRow; i++) { for (let i = minRow2; i <= maxRow; i++) {
if (state.list[i]) state.list[i][field.id] = newValue; if (state.list[i] && state.list[i][field.id] !== newValue) {
state.list[i][field.id] = newValue;
hasChanges = true;
} }
} }
}
if (hasChanges) {
state.list = [...state.list]; state.list = [...state.list];
state.isDirty = true; state.isDirty = true;
} }
}
_editorOverlay.style.display = "none"; _editorOverlay.style.display = "none";
if (form) { if (form) {
form.state.schema = []; form.state.schema = [];
@ -692,6 +721,7 @@
}; };
container.onMainDblClick = (e) => { container.onMainDblClick = (e) => {
var _a, _b, _c; var _a, _b, _c;
if (!container.editable) return;
const cell = e.target.closest(".dt-cell"), row = cell == null ? void 0 : cell.closest(".dt-row"); const cell = e.target.closest(".dt-cell"), row = cell == null ? void 0 : cell.closest(".dt-row");
if (row && !row.classList.contains("dt-header-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); 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);
@ -837,11 +867,21 @@
if ((overlay == null ? void 0 : overlay.style.display) !== "none" && !overlay.contains(e.target)) container.hideEditor(true); 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)) && !(menu == null ? void 0 : menu.contains(e.target))) selection.clearAllActive(); if (!container.contains(e.target) && !(overlay == null ? void 0 : overlay.contains(e.target)) && !(menu == null ? void 0 : menu.contains(e.target))) selection.clearAllActive();
}); });
container.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
e.preventDefault();
selection.copy();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
e.preventDefault();
selection.paste();
}
});
state._MODE_ICONS = MODE_ICONS; state._MODE_ICONS = MODE_ICONS;
}, globalThis.Util.makeDom( }, globalThis.Util.makeDom(
/*html*/ /*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" tabindex="0"> <div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-visible" 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()" <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"> style="overflow-anchor:none; min-height: 0">
@ -869,6 +909,7 @@
</template> </template>
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div> <div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
</div> </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); padding: 0;"><AutoForm nobutton inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"></AutoForm></div>
</div> </div>
<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)"> <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)">
@ -878,23 +919,26 @@
<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> <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>
<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"> <template $if="this.state?.activeModes?.length">
<div class="py-2 border-bottom" style="min-height: 48px"> <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();}"> <div class="input-group input-group-sm mb-1">
<input type="text" class="form-control" $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();}">
<button class="btn btn-outline-secondary border dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Search Mode">
<i $class="bi \${this.state?._MODE_ICONS?.[this.state?.filterConfig?.[this.state?.activeFieldId]?.mode] || 'bi-filter'}"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<template $each="this.state?.activeModes || []" as="m">
<li><button class="dropdown-item d-flex align-items-center" type="button" $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'} me-2"></i> <span $text="m.toUpperCase()"></span>
</button></li>
</template>
</ul>
</div>
<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();}"> <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> </div>
</template> </template>
<div class="mt-2" style="max-height: 180px; overflow-y: auto;"> <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] || []"> <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'"> <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))"> <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))">
@ -908,39 +952,37 @@
<span class="cursor-pointer text-primary small fw-bold" $onclick="this.clearColumnSettings()"><i class="bi bi-x-circle me-1"></i> Clear Filter</span> <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>
<div class="mt-3 pt-2 border-top d-flex flex-column gap-1"> <div $if="this.state?.editable" class="mt-3 pt-2 border-top d-flex gap-1 justify-content-between">
<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-sm btn-light border-0 flex-grow-1" title="Edit Field" $onclick="this.editField()"><i class="bi bi-pencil"></i></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-sm btn-light border-0 flex-grow-1" title="Add Field" $onclick="this.addField()"><i class="bi bi-plus-lg"></i></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> <button class="btn btn-sm btn-light border-0 flex-grow-1 text-danger" title="Delete Field" $onclick="this.deleteField()"><i class="bi bi-trash"></i></button>
</div> </div>
</template> </template>
</div> </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 nobutton inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"/></div>
<Modal $.id="this.id + '_field_modal'"> <Modal $.id="this.id + '_field_modal'">
<div slot="body"><AutoForm nobutton class="p-3"></AutoForm></div> <div slot="body"><AutoForm nobutton class="p-3"></AutoForm></div>
<div slot="footer"> <div slot="footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" $onclick="this.closest('Modal').result=1">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" $onclick="thisNode.closest('Modal').result=1">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" $onclick="this.closest('Modal').result=2">Save</button> <button type="button" class="btn btn-primary" data-bs-dismiss="modal" $onclick="thisNode.closest('Modal').result=2">Save</button>
</div> </div>
</Modal> </Modal>
<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="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="d-flex align-items-center gap-3 flex-grow-1">
<div class="btn-group shadow-sm"> <div $if="this.state?.editable" class="d-flex align-items-center gap-1">
<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-sm btn-light border-0 d-flex align-items-center px-2 py-1" $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> <button class="btn btn-sm btn-light border-0 d-flex align-items-center px-2 py-1" $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>
<div class="vr h-50 my-auto text-muted opacity-25"></div> <div $if="this.state?.editable" 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"> <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> <i class="bi bi-check-all fs-6"></i>
<span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span> <span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span>
</div> </div>
</div> </div>
<div class="d-flex align-items-center gap-2"> <div $if="this.state?.editable" 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-sm btn-primary border-0 px-3 shadow-sm d-flex align-items-center fw-bold" $onclick="this.saveChanges()"><i class="bi bi-cloud-upload me-1"></i> Save</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> <button $if="!this.state?.isDirty" class="btn btn-sm btn-light border-0 px-3 text-muted disabled d-flex align-items-center" disabled><i class="bi bi-cloud-check me-1"></i> Saved</button>
</div> </div>
</div> </div>
</div> </div>
@ -952,6 +994,7 @@
DataTable { display: block; } DataTable { display: block; }
.dt-root { font-size: 0.875rem; } .dt-root { font-size: 0.875rem; }
.dt-row, .dt-header-row { display: grid; grid-template-columns: var(--dt-grid-template); width: var(--dt-row-width, max-content); min-width: 100%; height: 40px; contain: paint layout; } .dt-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-main { position: relative; }
.dt-header-row { background-color: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); } .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 { 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-cell-selected { background-color: rgba(var(--bs-primary-rgb), 0.15) !important; outline: 1px solid var(--bs-primary); outline-offset: -1px; }
@ -965,6 +1008,11 @@
.dt-filter-tabs div:hover i { color: var(--bs-primary); } .dt-filter-tabs div:hover i { color: var(--bs-primary); }
.menu-item-row .only-btn { opacity: 0; } .menu-item-row .only-btn { opacity: 0; }
.menu-item-row:hover .only-btn { opacity: 1; } .menu-item-row:hover .only-btn { opacity: 1; }
.dt-editor-overlay .auto-form-root form { gap: 0 !important; margin: 0 !important; height: 100%; }
.dt-editor-overlay [control-wrapper] { width: 100%; margin: 0 !important; min-height: 100% !important; align-items: stretch !important; }
.dt-editor-overlay [control-wrapper] > .d-flex { padding: 0.375rem 0.5rem; justify-content: flex-start !important; align-items: center !important; }
.dt-editor-overlay [control-wrapper] > .form-switch { padding-left: 2.5rem !important; }
.dt-editor-overlay [control-wrapper] > textarea { min-height: 100px; resize: vertical; }
</style> </style>
` `
)); ));

File diff suppressed because one or more lines are too long

8
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "@web/datatable", "name": "@apigo.cc/datatable",
"version": "1.0.9", "version": "1.0.15",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@web/datatable", "name": "@apigo.cc/datatable",
"version": "1.0.9", "version": "1.0.15",
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.40.0", "@playwright/test": "^1.40.0",
"@rollup/plugin-terser": "^1.0.0", "@rollup/plugin-terser": "^1.0.0",

View File

@ -1,6 +1,6 @@
{ {
"name": "@web/datatable", "name": "@apigo.cc/datatable",
"version": "1.0.9", "version": "1.0.15",
"type": "module", "type": "module",
"main": "dist/datatable.js", "main": "dist/datatable.js",
"module": "dist/datatable.js", "module": "dist/datatable.js",
@ -10,7 +10,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"test": "playwright test" "test": "playwright test",
"pub": "node scripts/publish.js"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.40.0", "@playwright/test": "^1.40.0",

46
scripts/publish.js Normal file
View File

@ -0,0 +1,46 @@
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
try {
// 1. 获取最新 tag
let tag;
try {
tag = execSync('git describe --tags --abbrev=0', { encoding: 'utf8' }).trim();
} catch (err) {
throw new Error('Failed to find git tags. Please make sure the repository has tags (e.g., v1.0.0) before publishing.');
}
// 去掉 v 前缀
const version = tag.startsWith('v') ? tag.slice(1) : tag;
console.log(`Latest git tag: ${tag}, Version to publish: ${version}`);
// 2. 读取并更新 package.json
const pkgPath = path.join(__dirname, '../package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
// npm 要求包名全小写scope + name 强制 toLowerCase
const baseName = (pkg.name.includes('/') ? pkg.name.split('/')[1] : pkg.name).toLowerCase();
pkg.name = `@apigo.cc/${baseName}`;
pkg.version = version;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
console.log(`Updated package.json: name=${pkg.name}, version=${pkg.version}`);
// 3. 构建
console.log('Running build...');
execSync('npm run build', { stdio: 'inherit', cwd: path.join(__dirname, '..') });
// 4. 发布
console.log('Publishing to npm...');
const args = process.argv.slice(2).join(' ');
execSync(`npm publish --access public ${args}`, { stdio: 'inherit', cwd: path.join(__dirname, '..') });
console.log('Publish successful!');
} catch (error) {
console.error('Publish failed:', error.message);
process.exit(1);
}

View File

@ -229,7 +229,11 @@ const createSelectionManager = (container, state) => {
} }
cells.push(current); return cells; cells.push(current); return cells;
}); });
const { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds; let { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds;
if (minRow === maxRow && minCol === maxCol) {
maxRow = Infinity;
maxCol = Infinity;
}
const body = container.querySelector('.dt-body'); const body = container.querySelector('.dt-body');
const rowNodes = body ? Array.from(body.childNodes).filter(n => n.classList?.contains('dt-body-row')) : []; const rowNodes = body ? Array.from(body.childNodes).filter(n => n.classList?.contains('dt-body-row')) : [];
let anyRowChanged = false; let anyRowChanged = false;
@ -249,6 +253,7 @@ const createSelectionManager = (container, state) => {
globalThis.Component.register('DataTable', container => { globalThis.Component.register('DataTable', container => {
if (!container.state) container.state = globalThis.NewState({}) if (!container.state) container.state = globalThis.NewState({})
const state = container.state const state = container.state
container.editable = container.hasAttribute('editable')
Object.assign(state, { Object.assign(state, {
list: [], fields: [], _renderedList: [], list: [], fields: [], _renderedList: [],
@ -259,7 +264,8 @@ globalThis.Component.register('DataTable', container => {
filterConfig: {}, filterConfig: {},
activeFieldId: null, activeField: null, activeModes: [], activeFieldId: null, activeField: null, activeModes: [],
_columnStats: {}, _internalUpdate: false, _appliedHash: '', _fieldsDirty: false, _masterCellNodes: null, _columnStats: {}, _internalUpdate: false, _appliedHash: '', _fieldsDirty: false, _masterCellNodes: null,
isDirty: false, isBulkEdit: null isDirty: false, isBulkEdit: null,
editable: container.editable
}) })
const perf = createPerfMonitor(); const perf = createPerfMonitor();
@ -288,6 +294,7 @@ globalThis.Component.register('DataTable', container => {
container.onScroll = () => { container.onScroll = () => {
perf.onScroll(); scroll.refresh(); perf.onScroll(); scroll.refresh();
container.hideColumnMenu(); container.hideColumnMenu();
container.hideEditor(true);
const prev = container.querySelector('.dt-spacer-prev'), post = container.querySelector('.dt-spacer-post'); 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 (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'; } if (post) { post.style.height = (state.postHeight || 0) + 'px'; post.style.display = state.postHeight > 0 ? 'block' : 'none'; }
@ -338,6 +345,11 @@ globalThis.Component.register('DataTable', container => {
container.showColumnMenu = (field, event) => { container.showColumnMenu = (field, event) => {
const btn = event.currentTarget, menu = container.querySelector('.dt-column-menu'); const btn = event.currentTarget, menu = container.querySelector('.dt-column-menu');
if (menu.style.display === 'block' && state.activeFieldId === field.id) {
container.hideColumnMenu();
container.applySortFilter();
return;
}
const type = field.settings?.formType || field.type || 'text'; const type = field.settings?.formType || field.type || 'text';
state.activeModes = MODE_MAP[type] || (['boolean', 'switch', 'checkbox', 'radio'].includes(type) ? [] : MODE_MAP.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: [] }; if (!state.filterConfig[field.id]) state.filterConfig[field.id] = { mode: state.activeModes[0] || 'contains', value: '', selectedValues: [] };
@ -427,21 +439,33 @@ globalThis.Component.register('DataTable', container => {
}); });
container.editCell = (row, field, cellNode) => { container.editCell = (row, field, cellNode) => {
const overlay = container.querySelector('.dt-editor-overlay'), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect(); const main = container.querySelector('.dt-main');
const overlay = container.querySelector('.dt-editor-overlay'), rect = cellNode.getBoundingClientRect(), mainRect = main.getBoundingClientRect();
currentEditingNode = cellNode; currentEditingNode = cellNode;
const formType = field.settings?.formType || field.type || 'text'; const formType = field.settings?.formType || field.type || 'text';
const form = overlay.querySelector('AutoForm'); const form = overlay.querySelector('AutoForm');
if (form) { if (form) {
form.data = row; form.data = globalThis.NewState(globalThis.Util.clone(row));
form.state.schema = [{ ...field, type: formType, options: field.settings?.options || field.options, name: field.id, label: '' }]; form.state.schema = [{ ...field, type: formType, options: field.settings?.options || field.options, name: field.id, label: '' }];
} }
const isComplex = ['textarea', 'TagsInput', 'checkbox', 'radio'].includes(formType); const isComplex = ['textarea', 'TagsInput', 'checkbox', 'radio'].includes(formType);
const minWidth = isComplex ? 300 : 200; let topPos = rect.top - mainRect.top + main.scrollTop - 1;
let leftPos = rect.left - mainRect.left + main.scrollLeft - 1;
let editorWidth = Math.max(rect.width + 2, isComplex ? 300 : 0);
const maxLeft = main.scrollWidth - editorWidth - 5;
if (leftPos > maxLeft) leftPos = Math.max(0, maxLeft);
Object.assign(overlay.style, { Object.assign(overlay.style, {
display: 'flex', left: (rect.left - rootRect.left) + 'px', top: (rect.top - rootRect.top) + 'px', display: 'flex',
width: Math.max(rect.width, minWidth) + 'px', left: leftPos + 'px',
top: topPos + 'px',
width: editorWidth + 'px',
height: 'auto', height: 'auto',
minHeight: rect.height + 'px' minHeight: (rect.height + 2) + 'px',
maxHeight: Math.max(100, mainRect.height - (rect.top - mainRect.top) - 5) + 'px',
overflow: 'auto',
padding: '0'
}); });
setTimeout(() => overlay.querySelector('input, textarea, select, .form-control')?.focus(), 30); setTimeout(() => overlay.querySelector('input, textarea, select, .form-control')?.focus(), 30);
}; };
@ -454,20 +478,31 @@ globalThis.Component.register('DataTable', container => {
const input = _editorOverlay.querySelector('input:focus, select:focus, textarea:focus'); 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 (input) input.dispatchEvent(new Event(input.type === 'number' || input.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }));
let hasChanges = false;
const schema = form.state.schema || []; const schema = form.state.schema || [];
schema.forEach(field => { schema.forEach(field => {
const row = currentEditingNode?.closest('.dt-row')?._ref?.item; const row = currentEditingNode?.closest('.dt-row')?._ref?.item;
if (row) row[field.name] = form.data[field.name]; if (row && JSON.stringify(row[field.name]) !== JSON.stringify(form.data[field.name])) {
row[field.name] = form.data[field.name];
hasChanges = true;
}
}); });
if (state.isBulkEdit) { if (state.isBulkEdit) {
const { minRow, maxRow, fIdx } = state.isBulkEdit; const { minRow, maxRow, fIdx } = state.isBulkEdit;
const field = state.fields[fIdx]; const newValue = form.data[field.id]; 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; } for (let i = minRow; i <= maxRow; i++) {
if (state.list[i] && state.list[i][field.id] !== newValue) {
state.list[i][field.id] = newValue;
hasChanges = true;
} }
}
}
if (hasChanges) {
state.list = [...state.list]; state.list = [...state.list];
state.isDirty = true; state.isDirty = true;
} }
}
_editorOverlay.style.display = 'none'; _editorOverlay.style.display = 'none';
if (form) { form.state.schema = []; form.data = null; } if (form) { form.state.schema = []; form.data = null; }
currentEditingNode = null; state.isBulkEdit = null; container.focus(); currentEditingNode = null; state.isBulkEdit = null; container.focus();
@ -493,6 +528,7 @@ globalThis.Component.register('DataTable', container => {
}; };
container.onMainDblClick = e => { container.onMainDblClick = e => {
if (!container.editable) return;
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row'); const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (row && !row.classList.contains('dt-header-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 item = row._ref?.item, fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
@ -614,9 +650,20 @@ globalThis.Component.register('DataTable', container => {
if (!container.contains(e.target) && !overlay?.contains(e.target) && !menu?.contains(e.target)) selection.clearAllActive(); if (!container.contains(e.target) && !overlay?.contains(e.target) && !menu?.contains(e.target)) selection.clearAllActive();
}); });
container.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
e.preventDefault();
selection.copy();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
e.preventDefault();
selection.paste();
}
});
state._MODE_ICONS = MODE_ICONS; state._MODE_ICONS = MODE_ICONS;
}, globalThis.Util.makeDom(/*html*/` }, globalThis.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" tabindex="0"> <div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-visible" 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()" <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"> style="overflow-anchor:none; min-height: 0">
@ -644,6 +691,7 @@ globalThis.Component.register('DataTable', container => {
</template> </template>
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div> <div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
</div> </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); padding: 0;"><AutoForm nobutton inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"></AutoForm></div>
</div> </div>
<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)"> <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)">
@ -653,23 +701,26 @@ globalThis.Component.register('DataTable', container => {
<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> <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>
<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"> <template $if="this.state?.activeModes?.length">
<div class="py-2 border-bottom" style="min-height: 48px"> <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();}"> <div class="input-group input-group-sm mb-1">
<input type="text" class="form-control" $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();}">
<button class="btn btn-outline-secondary border dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Search Mode">
<i $class="bi \${this.state?._MODE_ICONS?.[this.state?.filterConfig?.[this.state?.activeFieldId]?.mode] || 'bi-filter'}"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<template $each="this.state?.activeModes || []" as="m">
<li><button class="dropdown-item d-flex align-items-center" type="button" $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'} me-2"></i> <span $text="m.toUpperCase()"></span>
</button></li>
</template>
</ul>
</div>
<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();}"> <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> </div>
</template> </template>
<div class="mt-2" style="max-height: 180px; overflow-y: auto;"> <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] || []"> <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'"> <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))"> <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))">
@ -683,39 +734,37 @@ globalThis.Component.register('DataTable', container => {
<span class="cursor-pointer text-primary small fw-bold" $onclick="this.clearColumnSettings()"><i class="bi bi-x-circle me-1"></i> Clear Filter</span> <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>
<div class="mt-3 pt-2 border-top d-flex flex-column gap-1"> <div $if="this.state?.editable" class="mt-3 pt-2 border-top d-flex gap-1 justify-content-between">
<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-sm btn-light border-0 flex-grow-1" title="Edit Field" $onclick="this.editField()"><i class="bi bi-pencil"></i></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-sm btn-light border-0 flex-grow-1" title="Add Field" $onclick="this.addField()"><i class="bi bi-plus-lg"></i></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> <button class="btn btn-sm btn-light border-0 flex-grow-1 text-danger" title="Delete Field" $onclick="this.deleteField()"><i class="bi bi-trash"></i></button>
</div> </div>
</template> </template>
</div> </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 nobutton inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"/></div>
<Modal $.id="this.id + '_field_modal'"> <Modal $.id="this.id + '_field_modal'">
<div slot="body"><AutoForm nobutton class="p-3"></AutoForm></div> <div slot="body"><AutoForm nobutton class="p-3"></AutoForm></div>
<div slot="footer"> <div slot="footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" $onclick="this.closest('Modal').result=1">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" $onclick="thisNode.closest('Modal').result=1">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" $onclick="this.closest('Modal').result=2">Save</button> <button type="button" class="btn btn-primary" data-bs-dismiss="modal" $onclick="thisNode.closest('Modal').result=2">Save</button>
</div> </div>
</Modal> </Modal>
<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="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="d-flex align-items-center gap-3 flex-grow-1">
<div class="btn-group shadow-sm"> <div $if="this.state?.editable" class="d-flex align-items-center gap-1">
<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-sm btn-light border-0 d-flex align-items-center px-2 py-1" $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> <button class="btn btn-sm btn-light border-0 d-flex align-items-center px-2 py-1" $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>
<div class="vr h-50 my-auto text-muted opacity-25"></div> <div $if="this.state?.editable" 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"> <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> <i class="bi bi-check-all fs-6"></i>
<span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span> <span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span>
</div> </div>
</div> </div>
<div class="d-flex align-items-center gap-2"> <div $if="this.state?.editable" 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-sm btn-primary border-0 px-3 shadow-sm d-flex align-items-center fw-bold" $onclick="this.saveChanges()"><i class="bi bi-cloud-upload me-1"></i> Save</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> <button $if="!this.state?.isDirty" class="btn btn-sm btn-light border-0 px-3 text-muted disabled d-flex align-items-center" disabled><i class="bi bi-cloud-check me-1"></i> Saved</button>
</div> </div>
</div> </div>
</div> </div>
@ -724,6 +773,7 @@ globalThis.Component.register('DataTable', container => {
DataTable { display: block; } DataTable { display: block; }
.dt-root { font-size: 0.875rem; } .dt-root { font-size: 0.875rem; }
.dt-row, .dt-header-row { display: grid; grid-template-columns: var(--dt-grid-template); width: var(--dt-row-width, max-content); min-width: 100%; height: 40px; contain: paint layout; } .dt-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-main { position: relative; }
.dt-header-row { background-color: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); } .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 { 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-cell-selected { background-color: rgba(var(--bs-primary-rgb), 0.15) !important; outline: 1px solid var(--bs-primary); outline-offset: -1px; }
@ -737,6 +787,11 @@ globalThis.Component.register('DataTable', container => {
.dt-filter-tabs div:hover i { color: var(--bs-primary); } .dt-filter-tabs div:hover i { color: var(--bs-primary); }
.menu-item-row .only-btn { opacity: 0; } .menu-item-row .only-btn { opacity: 0; }
.menu-item-row:hover .only-btn { opacity: 1; } .menu-item-row:hover .only-btn { opacity: 1; }
.dt-editor-overlay .auto-form-root form { gap: 0 !important; margin: 0 !important; height: 100%; }
.dt-editor-overlay [control-wrapper] { width: 100%; margin: 0 !important; min-height: 100% !important; align-items: stretch !important; }
.dt-editor-overlay [control-wrapper] > .d-flex { padding: 0.375rem 0.5rem; justify-content: flex-start !important; align-items: center !important; }
.dt-editor-overlay [control-wrapper] > .form-switch { padding-left: 2.5rem !important; }
.dt-editor-overlay [control-wrapper] > textarea { min-height: 100px; resize: vertical; }
</style> </style>
`)) `))

View File

@ -3,7 +3,6 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<title>AutoForm Diagnostic</title> <title>AutoForm Diagnostic</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="lib/state.js"></script> <script src="lib/state.js"></script>
<script src="lib/base.js"></script> <script src="lib/base.js"></script>
<script src="lib/datatable.js"></script> <script src="lib/datatable.js"></script>
@ -46,6 +45,7 @@
</script> </script>
</head> </head>
<body class="p-4"> <body class="p-4">
<script src="lib/bootstrap.js"></script>
<div style="width: 300px; border: 1px solid blue; min-height: 100px;"> <div style="width: 300px; border: 1px solid blue; min-height: 100px;">
<AutoForm id="diagForm" inline></AutoForm> <AutoForm id="diagForm" inline></AutoForm>
</div> </div>

View File

@ -5,9 +5,6 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DataTable Demo</title> <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> <style>
body, html { height: 100%; margin: 0; padding: 0; overflow: hidden; } body, html { height: 100%; margin: 0; padding: 0; overflow: hidden; }
.demo-container { height: 100vh; padding: 20px; display: flex; flex-direction: column; } .demo-container { height: 100vh; padding: 20px; display: flex; flex-direction: column; }
@ -16,9 +13,11 @@
</head> </head>
<body class="bg-light"> <body class="bg-light">
<!-- 0. Bootstrap -->
<script src="lib/bootstrap.js"></script>
<div class="demo-container"> <div class="demo-container">
<div class="table-wrapper"> <div class="table-wrapper">
<DataTable id="myTable"></DataTable> <DataTable id="myTable" editable></DataTable>
</div> </div>
</div> </div>

View File

@ -127,14 +127,14 @@
<option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option> <option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option>
</select> </select>
<div $if="['checkbox', 'radio'].includes(item.type)" class="d-flex align-items-center flex-wrap gap-3 h-100"> <div $if="['checkbox', 'radio'].includes(item.type)" class="d-flex align-items-center flex-wrap gap-3 h-100" style="padding: 0 0.75rem; min-height: calc(2.25rem + 2px);">
<label $each="item.options || [item.text||item.label||item.name]" as="opt" class="form-check mb-0 d-flex align-items-center" style="padding-left:0; cursor:pointer;"> <label $each="item.options || [item.text||item.label||item.name]" as="opt" class="form-check mb-0 d-flex align-items-center" style="padding-left:0; cursor:pointer;">
<input $name="item.name" class="form-check-input m-0 me-2" style="float:none;" $type="item.type" $.="item.setting || {}" $value="item.options?opt:'on'" $bind="this.data[item.name]"> <input $name="item.name" class="form-check-input m-0 me-2" style="float:none;" $type="item.type" $.="item.setting || {}" $value="item.options ? (opt.value !== undefined ? opt.value : opt) : 'on'" $bind="this.data[item.name]">
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="opt.label || opt" class="form-check-label"></span> <span $if="!this.inline || (item.options && item.options.length > 0)" $text="opt.label || opt" class="form-check-label"></span>
</label> </label>
</div> </div>
<div $if="item.type === 'switch'" class="form-check form-switch fs-5 d-flex align-items-center m-0" style="padding-left:0; min-height:0; display:flex !important;"> <div $if="item.type === 'switch'" class="form-check form-switch fs-5 d-flex align-items-center m-0" style="padding: 0 0.75rem; min-height: calc(2.25rem + 2px); display:flex !important;">
<input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" type="checkbox" $bind="this.data[item.name]"> <input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" type="checkbox" $bind="this.data[item.name]">
</div> </div>

7285
test/lib/bootstrap.js vendored Normal file

File diff suppressed because one or more lines are too long

2128
test/lib/bootstrap.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -276,14 +276,14 @@
}; };
const getSelectionBounds = () => { const getSelectionBounds = () => {
if (!activeBounds) return null; if (!activeBounds) return null;
let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow, minCol = activeBounds.minCol, maxCol = activeBounds.maxCol; let minRow2 = activeBounds.minRow, maxRow = activeBounds.maxRow, minCol2 = activeBounds.minCol, maxCol = activeBounds.maxCol;
multiSelections.forEach((s) => { multiSelections.forEach((s) => {
minRow = Math.min(minRow, s.minRow); minRow2 = Math.min(minRow2, s.minRow);
maxRow = Math.max(maxRow, s.maxRow); maxRow = Math.max(maxRow, s.maxRow);
minCol = Math.min(minCol, s.minCol); minCol2 = Math.min(minCol2, s.minCol);
maxCol = Math.max(maxCol, s.maxCol); maxCol = Math.max(maxCol, s.maxCol);
}); });
return { minRow, maxRow, minCol, maxCol }; return { minRow: minRow2, maxRow, minCol: minCol2, maxCol };
}; };
const copy = async () => { const copy = async () => {
const bounds = getSelectionBounds(); const bounds = getSelectionBounds();
@ -321,7 +321,11 @@
cells.push(current); cells.push(current);
return cells; return cells;
}); });
const { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds; let { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds;
if (minRow === maxRow && minCol === maxCol) {
maxRow = Infinity;
maxCol = Infinity;
}
const body = container.querySelector(".dt-body"); const body = container.querySelector(".dt-body");
const rowNodes = body ? Array.from(body.childNodes).filter((n) => { const rowNodes = body ? Array.from(body.childNodes).filter((n) => {
var _a; var _a;
@ -352,6 +356,7 @@
globalThis.Component.register("DataTable", (container) => { globalThis.Component.register("DataTable", (container) => {
if (!container.state) container.state = globalThis.NewState({}); if (!container.state) container.state = globalThis.NewState({});
const state = container.state; const state = container.state;
container.editable = container.hasAttribute("editable");
Object.assign(state, { Object.assign(state, {
list: [], list: [],
fields: [], fields: [],
@ -372,7 +377,8 @@
_fieldsDirty: false, _fieldsDirty: false,
_masterCellNodes: null, _masterCellNodes: null,
isDirty: false, isDirty: false,
isBulkEdit: null isBulkEdit: null,
editable: container.editable
}); });
const perf = createPerfMonitor(); const perf = createPerfMonitor();
state.perf = perf.stats; state.perf = perf.stats;
@ -400,6 +406,7 @@
perf.onScroll(); perf.onScroll();
scroll.refresh(); scroll.refresh();
container.hideColumnMenu(); container.hideColumnMenu();
container.hideEditor(true);
const prev = container.querySelector(".dt-spacer-prev"), post = container.querySelector(".dt-spacer-post"); const prev = container.querySelector(".dt-spacer-prev"), post = container.querySelector(".dt-spacer-post");
if (prev) { if (prev) {
prev.style.height = (state.prevHeight || 0) + "px"; prev.style.height = (state.prevHeight || 0) + "px";
@ -462,6 +469,11 @@
container.showColumnMenu = (field, event) => { container.showColumnMenu = (field, event) => {
var _a; var _a;
const btn = event.currentTarget, menu = container.querySelector(".dt-column-menu"); const btn = event.currentTarget, menu = container.querySelector(".dt-column-menu");
if (menu.style.display === "block" && state.activeFieldId === field.id) {
container.hideColumnMenu();
container.applySortFilter();
return;
}
const type = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text"; const type = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text";
state.activeModes = MODE_MAP[type] || (["boolean", "switch", "checkbox", "radio"].includes(type) ? [] : MODE_MAP.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: [] }; if (!state.filterConfig[field.id]) state.filterConfig[field.id] = { mode: state.activeModes[0] || "contains", value: "", selectedValues: [] };
@ -615,23 +627,31 @@
}); });
container.editCell = (row, field, cellNode) => { container.editCell = (row, field, cellNode) => {
var _a, _b; var _a, _b;
const overlay = container.querySelector(".dt-editor-overlay"), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect(); const main = container.querySelector(".dt-main");
const overlay = container.querySelector(".dt-editor-overlay"), rect = cellNode.getBoundingClientRect(), mainRect = main.getBoundingClientRect();
currentEditingNode = cellNode; currentEditingNode = cellNode;
const formType = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text"; const formType = ((_a = field.settings) == null ? void 0 : _a.formType) || field.type || "text";
const form = overlay.querySelector("AutoForm"); const form = overlay.querySelector("AutoForm");
if (form) { if (form) {
form.data = row; form.data = globalThis.NewState(globalThis.Util.clone(row));
form.state.schema = [{ ...field, type: formType, options: ((_b = field.settings) == null ? void 0 : _b.options) || field.options, name: field.id, label: "" }]; form.state.schema = [{ ...field, type: formType, options: ((_b = field.settings) == null ? void 0 : _b.options) || field.options, name: field.id, label: "" }];
} }
const isComplex = ["textarea", "TagsInput", "checkbox", "radio"].includes(formType); const isComplex = ["textarea", "TagsInput", "checkbox", "radio"].includes(formType);
const minWidth = isComplex ? 300 : 200; let topPos = rect.top - mainRect.top + main.scrollTop - 1;
let leftPos = rect.left - mainRect.left + main.scrollLeft - 1;
let editorWidth = Math.max(rect.width + 2, isComplex ? 300 : 0);
const maxLeft = main.scrollWidth - editorWidth - 5;
if (leftPos > maxLeft) leftPos = Math.max(0, maxLeft);
Object.assign(overlay.style, { Object.assign(overlay.style, {
display: "flex", display: "flex",
left: rect.left - rootRect.left + "px", left: leftPos + "px",
top: rect.top - rootRect.top + "px", top: topPos + "px",
width: Math.max(rect.width, minWidth) + "px", width: editorWidth + "px",
height: "auto", height: "auto",
minHeight: rect.height + "px" minHeight: rect.height + 2 + "px",
maxHeight: Math.max(100, mainRect.height - (rect.top - mainRect.top) - 5) + "px",
overflow: "auto",
padding: "0"
}); });
setTimeout(() => { setTimeout(() => {
var _a2; var _a2;
@ -645,23 +665,32 @@
if (save && form && form.data) { if (save && form && form.data) {
const input = _editorOverlay.querySelector("input:focus, select:focus, textarea:focus"); 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 (input) input.dispatchEvent(new Event(input.type === "number" || input.tagName === "SELECT" ? "change" : "input", { bubbles: true }));
let hasChanges = false;
const schema = form.state.schema || []; const schema = form.state.schema || [];
schema.forEach((field) => { schema.forEach((field) => {
var _a, _b; var _a, _b;
const row = (_b = (_a = currentEditingNode == null ? void 0 : currentEditingNode.closest(".dt-row")) == null ? void 0 : _a._ref) == null ? void 0 : _b.item; const row = (_b = (_a = currentEditingNode == null ? void 0 : currentEditingNode.closest(".dt-row")) == null ? void 0 : _a._ref) == null ? void 0 : _b.item;
if (row) row[field.name] = form.data[field.name]; if (row && JSON.stringify(row[field.name]) !== JSON.stringify(form.data[field.name])) {
row[field.name] = form.data[field.name];
hasChanges = true;
}
}); });
if (state.isBulkEdit) { if (state.isBulkEdit) {
const { minRow, maxRow, fIdx } = state.isBulkEdit; const { minRow: minRow2, maxRow, fIdx } = state.isBulkEdit;
const field = state.fields[fIdx]; const field = state.fields[fIdx];
const newValue = form.data[field.id]; const newValue = form.data[field.id];
for (let i = minRow; i <= maxRow; i++) { for (let i = minRow2; i <= maxRow; i++) {
if (state.list[i]) state.list[i][field.id] = newValue; if (state.list[i] && state.list[i][field.id] !== newValue) {
state.list[i][field.id] = newValue;
hasChanges = true;
} }
} }
}
if (hasChanges) {
state.list = [...state.list]; state.list = [...state.list];
state.isDirty = true; state.isDirty = true;
} }
}
_editorOverlay.style.display = "none"; _editorOverlay.style.display = "none";
if (form) { if (form) {
form.state.schema = []; form.state.schema = [];
@ -692,6 +721,7 @@
}; };
container.onMainDblClick = (e) => { container.onMainDblClick = (e) => {
var _a, _b, _c; var _a, _b, _c;
if (!container.editable) return;
const cell = e.target.closest(".dt-cell"), row = cell == null ? void 0 : cell.closest(".dt-row"); const cell = e.target.closest(".dt-cell"), row = cell == null ? void 0 : cell.closest(".dt-row");
if (row && !row.classList.contains("dt-header-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); 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);
@ -837,11 +867,21 @@
if ((overlay == null ? void 0 : overlay.style.display) !== "none" && !overlay.contains(e.target)) container.hideEditor(true); 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)) && !(menu == null ? void 0 : menu.contains(e.target))) selection.clearAllActive(); if (!container.contains(e.target) && !(overlay == null ? void 0 : overlay.contains(e.target)) && !(menu == null ? void 0 : menu.contains(e.target))) selection.clearAllActive();
}); });
container.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "c") {
e.preventDefault();
selection.copy();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "v") {
e.preventDefault();
selection.paste();
}
});
state._MODE_ICONS = MODE_ICONS; state._MODE_ICONS = MODE_ICONS;
}, globalThis.Util.makeDom( }, globalThis.Util.makeDom(
/*html*/ /*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" tabindex="0"> <div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-visible" 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()" <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"> style="overflow-anchor:none; min-height: 0">
@ -869,6 +909,7 @@
</template> </template>
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div> <div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
</div> </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); padding: 0;"><AutoForm nobutton inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"></AutoForm></div>
</div> </div>
<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)"> <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)">
@ -878,23 +919,26 @@
<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> <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>
<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"> <template $if="this.state?.activeModes?.length">
<div class="py-2 border-bottom" style="min-height: 48px"> <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();}"> <div class="input-group input-group-sm mb-1">
<input type="text" class="form-control" $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();}">
<button class="btn btn-outline-secondary border dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Search Mode">
<i $class="bi \${this.state?._MODE_ICONS?.[this.state?.filterConfig?.[this.state?.activeFieldId]?.mode] || 'bi-filter'}"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<template $each="this.state?.activeModes || []" as="m">
<li><button class="dropdown-item d-flex align-items-center" type="button" $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'} me-2"></i> <span $text="m.toUpperCase()"></span>
</button></li>
</template>
</ul>
</div>
<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();}"> <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> </div>
</template> </template>
<div class="mt-2" style="max-height: 180px; overflow-y: auto;"> <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] || []"> <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'"> <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))"> <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))">
@ -908,39 +952,37 @@
<span class="cursor-pointer text-primary small fw-bold" $onclick="this.clearColumnSettings()"><i class="bi bi-x-circle me-1"></i> Clear Filter</span> <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>
<div class="mt-3 pt-2 border-top d-flex flex-column gap-1"> <div $if="this.state?.editable" class="mt-3 pt-2 border-top d-flex gap-1 justify-content-between">
<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-sm btn-light border-0 flex-grow-1" title="Edit Field" $onclick="this.editField()"><i class="bi bi-pencil"></i></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-sm btn-light border-0 flex-grow-1" title="Add Field" $onclick="this.addField()"><i class="bi bi-plus-lg"></i></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> <button class="btn btn-sm btn-light border-0 flex-grow-1 text-danger" title="Delete Field" $onclick="this.deleteField()"><i class="bi bi-trash"></i></button>
</div> </div>
</template> </template>
</div> </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 nobutton inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)"/></div>
<Modal $.id="this.id + '_field_modal'"> <Modal $.id="this.id + '_field_modal'">
<div slot="body"><AutoForm nobutton class="p-3"></AutoForm></div> <div slot="body"><AutoForm nobutton class="p-3"></AutoForm></div>
<div slot="footer"> <div slot="footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" $onclick="this.closest('Modal').result=1">Cancel</button> <button type="button" class="btn btn-secondary" data-bs-dismiss="modal" $onclick="thisNode.closest('Modal').result=1">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" $onclick="this.closest('Modal').result=2">Save</button> <button type="button" class="btn btn-primary" data-bs-dismiss="modal" $onclick="thisNode.closest('Modal').result=2">Save</button>
</div> </div>
</Modal> </Modal>
<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="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="d-flex align-items-center gap-3 flex-grow-1">
<div class="btn-group shadow-sm"> <div $if="this.state?.editable" class="d-flex align-items-center gap-1">
<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-sm btn-light border-0 d-flex align-items-center px-2 py-1" $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> <button class="btn btn-sm btn-light border-0 d-flex align-items-center px-2 py-1" $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>
<div class="vr h-50 my-auto text-muted opacity-25"></div> <div $if="this.state?.editable" 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"> <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> <i class="bi bi-check-all fs-6"></i>
<span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span> <span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span>
</div> </div>
</div> </div>
<div class="d-flex align-items-center gap-2"> <div $if="this.state?.editable" 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-sm btn-primary border-0 px-3 shadow-sm d-flex align-items-center fw-bold" $onclick="this.saveChanges()"><i class="bi bi-cloud-upload me-1"></i> Save</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> <button $if="!this.state?.isDirty" class="btn btn-sm btn-light border-0 px-3 text-muted disabled d-flex align-items-center" disabled><i class="bi bi-cloud-check me-1"></i> Saved</button>
</div> </div>
</div> </div>
</div> </div>
@ -952,6 +994,7 @@
DataTable { display: block; } DataTable { display: block; }
.dt-root { font-size: 0.875rem; } .dt-root { font-size: 0.875rem; }
.dt-row, .dt-header-row { display: grid; grid-template-columns: var(--dt-grid-template); width: var(--dt-row-width, max-content); min-width: 100%; height: 40px; contain: paint layout; } .dt-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-main { position: relative; }
.dt-header-row { background-color: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); } .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 { 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-cell-selected { background-color: rgba(var(--bs-primary-rgb), 0.15) !important; outline: 1px solid var(--bs-primary); outline-offset: -1px; }
@ -965,6 +1008,11 @@
.dt-filter-tabs div:hover i { color: var(--bs-primary); } .dt-filter-tabs div:hover i { color: var(--bs-primary); }
.menu-item-row .only-btn { opacity: 0; } .menu-item-row .only-btn { opacity: 0; }
.menu-item-row:hover .only-btn { opacity: 1; } .menu-item-row:hover .only-btn { opacity: 1; }
.dt-editor-overlay .auto-form-root form { gap: 0 !important; margin: 0 !important; height: 100%; }
.dt-editor-overlay [control-wrapper] { width: 100%; margin: 0 !important; min-height: 100% !important; align-items: stretch !important; }
.dt-editor-overlay [control-wrapper] > .d-flex { padding: 0.375rem 0.5rem; justify-content: flex-start !important; align-items: center !important; }
.dt-editor-overlay [control-wrapper] > .form-switch { padding-left: 2.5rem !important; }
.dt-editor-overlay [control-wrapper] > textarea { min-height: 100px; resize: vertical; }
</style> </style>
` `
)); ));

File diff suppressed because one or more lines are too long

View File

@ -4,7 +4,7 @@
"use strict"; "use strict";
var _a, _b; var _a, _b;
const Util = { const Util = {
clone: globalThis.structuredClone || ((obj) => JSON.parse(JSON.stringify(obj))), clone: (obj) => JSON.parse(JSON.stringify(obj)),
base64: (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str))), base64: (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
unbase64: (str) => new TextDecoder().decode(Uint8Array.from(atob(str), (c) => c.charCodeAt(0))), unbase64: (str) => new TextDecoder().decode(Uint8Array.from(atob(str), (c) => c.charCodeAt(0))),
urlbase64: (str) => Util.base64(str).replace(/[+/=]/g, (m) => ({ "+": "-", "/": "", "=": "" })[m]), urlbase64: (str) => Util.base64(str).replace(/[+/=]/g, (m) => ({ "+": "-", "/": "", "=": "" })[m]),
@ -385,6 +385,7 @@
if (existingNodes) { if (existingNodes) {
node._keyedNodes.delete(keyVal); node._keyedNodes.delete(keyVal);
existingNodes.forEach((child) => { existingNodes.forEach((child) => {
node.parentNode.insertBefore(child, node);
child._ref[indexName] = k; child._ref[indexName] = k;
child._ref[asName] = item; child._ref[asName] = item;
_scanTree(child); _scanTree(child);

View File

@ -23,7 +23,7 @@
const div = document.createElement('div'); const div = document.createElement('div');
div.setAttribute('style', 'height: 400px; width: 800px; border: 1px solid red'); div.setAttribute('style', 'height: 400px; width: 800px; border: 1px solid red');
div.innerHTML = '<DataTable id="dt" style="display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden"></DataTable>'; div.innerHTML = '<DataTable id="dt" editable style="display: flex; flex-direction: column; height: 100%; min-height: 0; overflow: hidden"></DataTable>';
document.body.appendChild(div); document.body.appendChild(div);
// Wait for MutationObserver to pick up the new element // Wait for MutationObserver to pick up the new element
@ -49,5 +49,7 @@
}; };
</script> </script>
</head> </head>
<body></body> <body>
<script src="lib/bootstrap.js"></script>
</body>
</html> </html>

View File

@ -5,9 +5,9 @@ import terser from '@rollup/plugin-terser';
export default defineConfig({ export default defineConfig({
resolve: { resolve: {
alias: { alias: {
'@web/state': resolve(__dirname, '../state/src/index.js'), '@apigo.cc/state': resolve(__dirname, '../state/src/index.js'),
'@web/base': resolve(__dirname, '../base/src/index.js'), '@apigo.cc/base': resolve(__dirname, '../base/src/index.js'),
'@web/datatable': resolve(__dirname, 'src/index.js') '@apigo.cc/datatable': resolve(__dirname, 'src/index.js')
} }
}, },
server: { server: {
@ -22,7 +22,7 @@ export default defineConfig({
formats: ['umd'] formats: ['umd']
}, },
rollupOptions: { rollupOptions: {
external: ['@web/state', '@web/base'], external: ['@apigo.cc/state', '@apigo.cc/base'],
output: [ output: [
{ {
format: 'umd', format: 'umd',
@ -30,8 +30,8 @@ export default defineConfig({
entryFileNames: 'datatable.js', entryFileNames: 'datatable.js',
extend: true, extend: true,
globals: { globals: {
'@web/state': 'ApigoState', '@apigo.cc/state': 'ApigoState',
'@web/base': 'ApigoBase' '@apigo.cc/base': 'ApigoBase'
} }
}, },
{ {
@ -40,8 +40,8 @@ export default defineConfig({
entryFileNames: 'datatable.min.js', entryFileNames: 'datatable.min.js',
extend: true, extend: true,
globals: { globals: {
'@web/state': 'ApigoState', '@apigo.cc/state': 'ApigoState',
'@web/base': 'ApigoBase' '@apigo.cc/base': 'ApigoBase'
}, },
plugins: [terser()] plugins: [terser()]
} }