refactor(ui): remove $if workaround and debugging attributes
This commit is contained in:
parent
6081140907
commit
8ba9480bb0
529
src/index.js
529
src/index.js
@ -4,6 +4,21 @@ import { createPerfMonitor } from './perf.js'
|
|||||||
import { createScrollManager } from './scroll.js'
|
import { createScrollManager } from './scroll.js'
|
||||||
import { createSelectionManager } from './selection.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'
|
||||||
|
};
|
||||||
|
|
||||||
Component.register('DataTable', container => {
|
Component.register('DataTable', container => {
|
||||||
if (!container.state) container.state = NewState({})
|
if (!container.state) container.state = NewState({})
|
||||||
const state = container.state
|
const state = container.state
|
||||||
@ -14,8 +29,11 @@ Component.register('DataTable', container => {
|
|||||||
selectedRowCount: 0,
|
selectedRowCount: 0,
|
||||||
_originalList: [],
|
_originalList: [],
|
||||||
sortConfig: { fieldId: null, direction: null },
|
sortConfig: { fieldId: null, direction: null },
|
||||||
filterConfig: {},
|
filterConfig: {}, // fieldId -> { mode, value, value2, selectedValues: [] }
|
||||||
activeFieldId: null,
|
activeFieldId: null,
|
||||||
|
activeField: null,
|
||||||
|
activeModes: [],
|
||||||
|
_columnStats: {},
|
||||||
_internalUpdate: false,
|
_internalUpdate: false,
|
||||||
_appliedHash: '',
|
_appliedHash: '',
|
||||||
_fieldsDirty: false,
|
_fieldsDirty: false,
|
||||||
@ -24,64 +42,30 @@ Component.register('DataTable', container => {
|
|||||||
|
|
||||||
const perf = createPerfMonitor();
|
const perf = createPerfMonitor();
|
||||||
state.perf = perf.stats;
|
state.perf = perf.stats;
|
||||||
|
|
||||||
const selection = createSelectionManager(container, state);
|
const selection = createSelectionManager(container, state);
|
||||||
|
const scroll = createScrollManager(container, state, () => selection.applySelectionUI());
|
||||||
|
|
||||||
const scroll = createScrollManager(container, state, (renderedCount, isLayoutChange) => {
|
const menuNode = container.querySelector('.dt-column-menu');
|
||||||
selection.applySelectionUI();
|
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) => {
|
container.format = (val, field) => {
|
||||||
if (field.formatter) return field.formatter(val, field);
|
if (field.formatter) return field.formatter(val, field);
|
||||||
if (typeof val === 'string') return val;
|
return val == null ? '' : (typeof val === 'object' ? JSON.stringify(val) : String(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();
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
let _scrollRaf = null;
|
|
||||||
container.onScroll = () => {
|
container.onScroll = () => {
|
||||||
perf.onScroll();
|
perf.onScroll(); scroll.refresh();
|
||||||
container.refresh();
|
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 (_scrollRaf) return;
|
if (post) { post.style.height = (state.postHeight || 0) + 'px'; post.style.display = state.postHeight > 0 ? 'block' : 'none'; }
|
||||||
_scrollRaf = requestAnimationFrame(() => {
|
|
||||||
_scrollRaf = null;
|
|
||||||
const menu = container.querySelector('.dt-column-menu');
|
|
||||||
if (menu && menu.style.display !== 'none' && state.activeFieldId) {
|
|
||||||
const headerCell = container.querySelector(`.header-cell[data-id="${state.activeFieldId}"]`);
|
|
||||||
if (headerCell) {
|
|
||||||
const btn = headerCell.querySelector('.header-menu-btn');
|
|
||||||
const btnRect = btn.getBoundingClientRect();
|
|
||||||
const rootRect = container.getBoundingClientRect();
|
|
||||||
menu.style.left = (btnRect.right - rootRect.left - 180) + 'px';
|
|
||||||
menu.style.top = (btnRect.bottom - rootRect.top + 5) + 'px';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
container.applySortFilter = (options = {}) => {
|
container.applySortFilter = (options = {}) => {
|
||||||
@ -92,71 +76,76 @@ Component.register('DataTable', container => {
|
|||||||
if (state._appliedHash === currentHash && !options.force) return;
|
if (state._appliedHash === currentHash && !options.force) return;
|
||||||
|
|
||||||
state._internalUpdate = true;
|
state._internalUpdate = true;
|
||||||
const startTime = performance.now();
|
let list = [...(state._originalList || [])];
|
||||||
let list = (options.force) ? state.list : [...(state._originalList || [])];
|
Object.keys(targetFilters).forEach(fieldId => {
|
||||||
|
const filter = targetFilters[fieldId];
|
||||||
if (!options.force) {
|
if (!filter) return;
|
||||||
Object.keys(targetFilters).forEach(fieldId => {
|
const { mode = 'contains', value, value2, selectedValues } = filter;
|
||||||
const val = targetFilters[fieldId];
|
if (selectedValues?.length > 0) { list = list.filter(item => selectedValues.includes(String(item[fieldId] ?? ''))); return; }
|
||||||
if (val) {
|
if (value === '' || value == null) return;
|
||||||
const lowerVal = String(val).toLowerCase();
|
const lowV = String(value).toLowerCase(), n1 = Number(value), n2 = Number(value2);
|
||||||
list = list.filter(item => String(item[fieldId] ?? '').toLowerCase().includes(lowerVal));
|
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 && targetSort.fieldId && targetSort.direction) {
|
|
||||||
list.sort((a, b) => {
|
|
||||||
let va = a[targetSort.fieldId], vb = b[targetSort.fieldId];
|
|
||||||
if (va === vb) return 0;
|
|
||||||
if (va === null || va === undefined) return 1;
|
|
||||||
if (vb === null || vb === undefined) return -1;
|
|
||||||
const res = va > vb ? 1 : -1;
|
|
||||||
return targetSort.direction === 'asc' ? res : -res;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.__perfTrace = { evalCount: 0, evalTotal: 0 };
|
|
||||||
const frameStart = performance.now();
|
|
||||||
state._appliedHash = currentHash;
|
|
||||||
if (options.sort !== undefined) state.sortConfig = targetSort;
|
|
||||||
state.list = list;
|
|
||||||
state._internalUpdate = false;
|
|
||||||
|
|
||||||
const frameEnd = performance.now();
|
|
||||||
const totalTime = frameEnd - startTime;
|
|
||||||
console.log(`[DataTable Performance Profile] Sync Block: ${totalTime.toFixed(2)}ms (Eval: ${window.__perfTrace.evalCount})`);
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log(`[DataTable Performance Profile] E2E Paint: ${(performance.now() - startTime).toFixed(2)}ms`);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
window.__perfTrace = null;
|
if (targetSort.fieldId && targetSort.direction) {
|
||||||
|
list.sort((a, b) => {
|
||||||
|
let va = a[targetSort.fieldId], vb = b[targetSort.fieldId];
|
||||||
|
if (va === vb) return 0;
|
||||||
|
const res = va > vb ? 1 : -1;
|
||||||
|
return targetSort.direction === 'asc' ? res : -res;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state._appliedHash = currentHash; state.sortConfig = targetSort; state.list = list; state._internalUpdate = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
container.showColumnMenu = (field, e) => {
|
container.showColumnMenu = (field, e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const btn = e.currentTarget;
|
const btn = e.currentTarget, menu = container.querySelector('.dt-column-menu');
|
||||||
const rect = btn.getBoundingClientRect();
|
const type = field.type || 'text';
|
||||||
const rootRect = container.getBoundingClientRect();
|
state.activeModes = MODE_MAP[type] || (['boolean', 'switch', 'checkbox', 'radio'].includes(type) ? [] : MODE_MAP.text);
|
||||||
const menu = container.querySelector('.dt-column-menu');
|
if (!state.filterConfig[field.id]) {
|
||||||
state.activeFieldId = field.id;
|
state.filterConfig[field.id] = { mode: state.activeModes[0] || 'contains', value: '', selectedValues: [] };
|
||||||
|
}
|
||||||
|
state.activeField = field; state.activeFieldId = field.id;
|
||||||
menu.style.display = 'block';
|
menu.style.display = 'block';
|
||||||
menu.style.left = (rect.right - rootRect.left - 180) + 'px';
|
const rect = btn.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
|
||||||
|
menu.style.left = (rect.right - rootRect.left - (menu.offsetWidth || 260)) + 'px';
|
||||||
menu.style.top = (rect.bottom - rootRect.top + 5) + 'px';
|
menu.style.top = (rect.bottom - rootRect.top + 5) + 'px';
|
||||||
|
|
||||||
const onGlobalClick = (ev) => {
|
const onGlobalClick = (ev) => {
|
||||||
if (menu.contains(ev.target)) return;
|
if (menu.contains(ev.target) || btn.contains(ev.target)) return;
|
||||||
if (!btn.contains(ev.target)) {
|
container.hideColumnMenu(); container.applySortFilter();
|
||||||
container.hideColumnMenu();
|
document.removeEventListener('mousedown', onGlobalClick);
|
||||||
container.applySortFilter();
|
|
||||||
document.removeEventListener('mousedown', onGlobalClick);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
document.addEventListener('mousedown', onGlobalClick);
|
document.addEventListener('mousedown', onGlobalClick);
|
||||||
RefreshState(menu);
|
setTimeout(() => menu.querySelector('input')?.focus(), 50);
|
||||||
setTimeout(() => {
|
};
|
||||||
const input = menu.querySelector('input');
|
|
||||||
if (input) input.focus();
|
container.toggleSelectedValue = (val) => {
|
||||||
}, 50);
|
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 = () => {
|
container.hideColumnMenu = () => {
|
||||||
@ -164,178 +153,141 @@ Component.register('DataTable', container => {
|
|||||||
if (menu) menu.style.display = 'none';
|
if (menu) menu.style.display = 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
container.setSort = (direction) => {
|
container.setSort = (dir) => {
|
||||||
container.applySortFilter({ sort: direction });
|
const newDir = state.sortConfig.direction === dir && state.sortConfig.fieldId === state.activeFieldId ? null : dir;
|
||||||
|
container.applySortFilter({ sort: newDir });
|
||||||
};
|
};
|
||||||
|
|
||||||
container.clearColumnSettings = () => {
|
container.clearColumnSettings = () => {
|
||||||
const filters = { ...state.filterConfig };
|
if (state.activeFieldId) {
|
||||||
delete filters[state.activeFieldId];
|
delete state.filterConfig[state.activeFieldId];
|
||||||
state.filterConfig = filters;
|
state.filterConfig = { ...state.filterConfig };
|
||||||
container.applySortFilter({ sort: null });
|
container.applySortFilter();
|
||||||
container.hideColumnMenu();
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- Optimized Context Initialization (Using _refExt) ---
|
|
||||||
container._initRow = (rowNode) => {
|
|
||||||
if (!rowNode || !rowNode.children) return;
|
|
||||||
// This runs during framework's parsing of the cloned row.
|
|
||||||
// We set _refExt on each cell clone BEFORE _scanTree visits them.
|
|
||||||
const cells = rowNode.children;
|
|
||||||
for (let i = 0; i < cells.length; i++) {
|
|
||||||
const cell = cells[i];
|
|
||||||
const fIdx = parseInt(cell.dataset.fidx);
|
|
||||||
if (!isNaN(fIdx)) {
|
|
||||||
cell._refExt = { f: state.fields[fIdx], fIdx: fIdx };
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
container.onMainMouseDown = e => {
|
container._initRow = (rowNode) => {
|
||||||
const cell = e.target.closest('.dt-cell');
|
const row = rowNode._ref?.item;
|
||||||
if (!cell) return;
|
if (row && row._editingF === undefined) {
|
||||||
const row = cell.closest('.dt-row');
|
Object.defineProperty(row, '_editingF', { set: (v) => { if (v === null) container.hideEditor(true); }, configurable: true });
|
||||||
if (!row || row.classList.contains('dt-header-row')) return;
|
}
|
||||||
const fIdx = cell.dataset.fidx !== undefined ? parseInt(cell.dataset.fidx) : (cell._ref?.fIdx ?? Array.from(row.children).indexOf(cell));
|
Array.from(rowNode.children).forEach(cell => {
|
||||||
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
const fIdx = parseInt(cell.dataset.fidx);
|
||||||
container.startSelect(rIdx + state._listStartIndex, fIdx, e);
|
if (!isNaN(fIdx)) cell._refExt = { f: state.fields[fIdx], fIdx: fIdx };
|
||||||
};
|
});
|
||||||
|
|
||||||
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;
|
|
||||||
const fIdx = cell.dataset.fidx !== undefined ? parseInt(cell.dataset.fidx) : (cell._ref?.fIdx ?? Array.from(row.children).indexOf(cell));
|
|
||||||
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
|
||||||
container.updateSelect(rIdx + state._listStartIndex, fIdx);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 fIdx = cell.dataset.fidx !== undefined ? parseInt(cell.dataset.fidx) : (cell._ref?.fIdx ?? Array.from(row.children).indexOf(cell));
|
|
||||||
const field = cell._ref?.f ?? state.fields[fIdx];
|
|
||||||
if (item && field) container.editCell(item, field, cell);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
state.__watch('fields', fields => {
|
state.__watch('fields', fields => {
|
||||||
if (!fields) return;
|
if (!fields) return;
|
||||||
state._fieldsDirty = true;
|
state._fieldsDirty = true;
|
||||||
const gridTemplate = fields.map(f => `var(--w-${f.id}, ${f.width || 150}px)`).join(' ');
|
container.style.setProperty('--dt-grid-template', fields.map(f => `var(--w-${f.id}, ${f.width || 150}px)`).join(' '));
|
||||||
const totalWidth = fields.reduce((sum, f) => sum + (f.width || 150), 0);
|
container.style.setProperty('--dt-row-width', fields.reduce((sum, f) => sum + (f.width || 150), 0) + 'px');
|
||||||
container.style.setProperty('--dt-grid-template', gridTemplate);
|
});
|
||||||
container.style.setProperty('--dt-row-width', totalWidth + 'px');
|
|
||||||
})
|
|
||||||
|
|
||||||
state.__watch('list', list => {
|
state.__watch('list', list => {
|
||||||
if (state._fieldsDirty) {
|
if (state._fieldsDirty) {
|
||||||
state._fieldsDirty = false;
|
state._fieldsDirty = false;
|
||||||
const rowTemplate = container.querySelector('.dt-body template[index="rIdx"]');
|
const fieldTemplate = container.querySelector('.dt-body template[index="rIdx"]')?.content.querySelector('template[as="f"]');
|
||||||
if (rowTemplate) {
|
if (fieldTemplate) {
|
||||||
const fieldTemplate = rowTemplate.content.querySelector('template[as="f"]');
|
const masters = state._masterCellNodes || (state._masterCellNodes = Array.from(fieldTemplate.content.childNodes).map(n => n.cloneNode(true)));
|
||||||
if (fieldTemplate) {
|
fieldTemplate.removeAttribute('$each'); fieldTemplate.setAttribute('$if', 'true'); fieldTemplate.content.textContent = '';
|
||||||
// 1. Capture the master copy of cell nodes once
|
state.fields.forEach((f, fIdx) => masters.forEach(master => {
|
||||||
if (!state._masterCellNodes) {
|
const clone = master.cloneNode(true); if (clone.nodeType === 1) clone.dataset.fidx = fIdx; fieldTemplate.content.appendChild(clone);
|
||||||
state._masterCellNodes = Array.from(fieldTemplate.content.childNodes).map(n => n.cloneNode(true));
|
}));
|
||||||
}
|
|
||||||
// 2. Transform the field template into a static flattened block
|
|
||||||
fieldTemplate.removeAttribute('$each');
|
|
||||||
fieldTemplate.removeAttribute('as');
|
|
||||||
fieldTemplate.removeAttribute('index');
|
|
||||||
fieldTemplate.setAttribute('$if', 'true'); // High-speed safe released block
|
|
||||||
fieldTemplate.content.textContent = ''; // Idiomatic way to clear document fragment
|
|
||||||
|
|
||||||
// 3. Inject flattened columns with position markers
|
|
||||||
state.fields.forEach((f, fIdx) => {
|
|
||||||
state._masterCellNodes.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 || [])];
|
if (!state._internalUpdate) {
|
||||||
scroll.init();
|
state._originalList = [...(list || [])];
|
||||||
scroll.reset(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);
|
||||||
|
});
|
||||||
|
|
||||||
let currentEditingNode = null;
|
|
||||||
container.editCell = (row, field, cellNode) => {
|
container.editCell = (row, field, cellNode) => {
|
||||||
const overlay = container.querySelector('.dt-editor-overlay');
|
const overlay = container.querySelector('.dt-editor-overlay'), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
|
||||||
const editor = overlay.querySelector('AutoForm');
|
|
||||||
const rect = cellNode.getBoundingClientRect();
|
|
||||||
const rootRect = container.getBoundingClientRect();
|
|
||||||
currentEditingNode = cellNode;
|
currentEditingNode = cellNode;
|
||||||
let minW = rect.width;
|
Object.assign(overlay.style, {
|
||||||
if (field.type === 'textarea' || field.type === 'TagsInput') minW = Math.max(rect.width, 300);
|
display: 'flex', left: (rect.left - rootRect.left) + 'px', top: (rect.top - rootRect.top) + 'px',
|
||||||
else if (field.type === 'radio') minW = Math.max(rect.width, 240);
|
width: (field.type === 'textarea' || field.type === 'TagsInput' ? Math.max(rect.width, 300) : rect.width) + 'px',
|
||||||
overlay.style.display = 'flex';
|
height: (field.type === 'textarea' || field.type === 'TagsInput' ? 'auto' : rect.height + 'px')
|
||||||
overlay.style.left = (rect.left - rootRect.left) + 'px';
|
});
|
||||||
overlay.style.top = (rect.top - rootRect.top) + 'px';
|
State.editingSchema = [{ ...field, name: field.id, label: '' }]; State.editingData = row;
|
||||||
overlay.style.width = minW + 'px';
|
setTimeout(() => overlay.querySelector('input, textarea, select, .form-control')?.focus(), 30);
|
||||||
overlay.style.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();
|
|
||||||
}, 30);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
container.hideEditor = (save = true) => {
|
container.hideEditor = (save = true) => {
|
||||||
if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay');
|
if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay');
|
||||||
if (!_editorOverlay || _editorOverlay.style.display === 'none') return;
|
if (!_editorOverlay || _editorOverlay.style.display === 'none') return;
|
||||||
_editorOverlay.style.display = 'none';
|
if (save) {
|
||||||
if (save && currentEditingNode) RefreshState(currentEditingNode);
|
const input = _editorOverlay.querySelector('input:focus, select:focus, textarea:focus');
|
||||||
State.editingSchema = State.editingData = currentEditingNode = null;
|
if (input) input.dispatchEvent(new Event(input.type === 'number' || input.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }));
|
||||||
|
if (currentEditingNode) RefreshState(currentEditingNode);
|
||||||
|
}
|
||||||
|
_editorOverlay.style.display = 'none'; State.editingSchema = State.editingData = currentEditingNode = null;
|
||||||
container.focus();
|
container.focus();
|
||||||
};
|
};
|
||||||
|
|
||||||
container.startSelect = selection.startSelect;
|
container.onMainMouseDown = e => {
|
||||||
container.updateSelect = selection.updateSelect;
|
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
|
||||||
container.deleteSelected = selection.deleteSelected;
|
if (!row || row.classList.contains('dt-header-row')) return;
|
||||||
|
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.children).indexOf(cell);
|
||||||
|
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
||||||
|
selection.startSelect(rIdx + state._listStartIndex, fIdx, e);
|
||||||
|
};
|
||||||
|
|
||||||
const onGlobalMouseDown = e => {
|
container.onMainMouseOver = e => {
|
||||||
const overlay = container.querySelector('.dt-editor-overlay');
|
if (state.isSelecting) {
|
||||||
if (overlay && overlay.style.display !== 'none' && !overlay.contains(e.target)) container.hideEditor(true);
|
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
|
||||||
if (!container.contains(e.target) && !overlay?.contains(e.target)) selection.clearAllActive();
|
if (row && !row.classList.contains('dt-header-row')) {
|
||||||
}
|
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.children).indexOf(cell);
|
||||||
|
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
||||||
|
selection.updateSelect(rIdx + state._listStartIndex, fIdx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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.children).indexOf(cell);
|
||||||
|
if (item && state.fields[fIdx]) container.editCell(item, state.fields[fIdx], cell);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
window.addEventListener('mouseup', selection.endSelect);
|
window.addEventListener('mouseup', selection.endSelect);
|
||||||
document.addEventListener('mousedown', onGlobalMouseDown);
|
document.addEventListener('mousedown', e => {
|
||||||
container._onUnload = () => {
|
const overlay = container.querySelector('.dt-editor-overlay');
|
||||||
document.removeEventListener('mousedown', onGlobalMouseDown);
|
if (overlay?.style.display !== 'none' && !overlay.contains(e.target)) container.hideEditor(true);
|
||||||
window.removeEventListener('mouseup', selection.endSelect);
|
if (!container.contains(e.target) && !overlay?.contains(e.target)) selection.clearAllActive();
|
||||||
}
|
});
|
||||||
|
|
||||||
|
// Exposure for templates
|
||||||
|
state._MODE_ICONS = MODE_ICONS;
|
||||||
|
|
||||||
}, Util.makeDom(/*html*/`
|
}, 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()"
|
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
|
||||||
$onmousedown="this.onMainMouseDown(event)"
|
$onmousedown="this.onMainMouseDown(event)" $onmouseover="this.onMainMouseOver(event)" $ondblclick="this.onMainDblClick(event)"
|
||||||
$onmouseover="this.onMainMouseOver(event)"
|
|
||||||
$ondblclick="this.onMainDblClick(event)"
|
|
||||||
style="overflow-anchor:none; min-height: 0">
|
style="overflow-anchor:none; min-height: 0">
|
||||||
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
|
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
|
||||||
<div class="dt-row dt-header-row fw-bold text-muted small">
|
<div class="dt-header-row fw-bold text-muted small">
|
||||||
<template $each="this.state?.fields || []">
|
<template $each="this.state?.fields || []">
|
||||||
<div $data-id="item.id" class="dt-cell border-end px-2 d-flex align-items-center justify-content-between header-cell">
|
<div $data-id="item.id" class="dt-cell dt-col border-end d-flex align-items-center header-cell" style="position:relative; padding: 0">
|
||||||
<div class="d-flex align-items-center overflow-hidden">
|
<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?.sortConfig?.fieldId === item.id"
|
<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>
|
||||||
$class="'bi bi-sort-' + (this.state?.sortConfig?.direction === 'asc' ? 'down' : 'up-alt') + ' 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>
|
||||||
<i $if="this.state?.filterConfig?.[item.id]" class="bi bi-filter me-1 text-primary"></i>
|
<span $text="item.name" class="text-truncate flex-grow-1"></span>
|
||||||
<span $text="item.name" class="text-truncate"></span>
|
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-xs btn-link text-muted p-0 border-0 header-menu-btn" $onclick="this.showColumnMenu(item, event)">
|
<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>
|
||||||
<i class="bi bi-chevron-down"></i>
|
<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>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@ -344,82 +296,69 @@ Component.register('DataTable', container => {
|
|||||||
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
|
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
|
||||||
<template $each="this.state?._renderedList || []" key="id" index="rIdx">
|
<template $each="this.state?._renderedList || []" key="id" index="rIdx">
|
||||||
<div class="dt-row dt-body-row border-bottom bg-white" $.="this._initRow(thisNode)">
|
<div class="dt-row dt-body-row border-bottom bg-white" $.="this._initRow(thisNode)">
|
||||||
<template $each="this.state?.fields || []" as="f" index="fIdx">
|
<template as="f"><div 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>
|
||||||
<div class="dt-cell border-end px-2 d-flex align-items-center">
|
|
||||||
<span $text="!f.formatter ? item[f.id] : this.format(item[f.id], f)" class="text-truncate"></span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div class="dt-column-menu border bg-body shadow-sm rounded" style="display:none; position:absolute; z-index:2000; min-width:180px" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
<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;">
|
||||||
<div class="list-group list-group-flush">
|
<template $if="this.state?.activeFieldId">
|
||||||
<button class="list-group-item list-group-item-action py-2 d-flex align-items-center" $onclick="this.setSort('asc')">
|
<div class="btn-group w-100 mb-3">
|
||||||
<i class="bi bi-sort-alpha-down me-2"></i> Sort Ascending
|
<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>
|
<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>
|
||||||
<button class="list-group-item list-group-item-action py-2 d-flex align-items-center" $onclick="this.setSort('desc')">
|
|
||||||
<i class="bi bi-sort-alpha-up-alt me-2"></i> Sort Descending
|
|
||||||
</button>
|
|
||||||
<div class="p-2 border-top bg-light-subtle">
|
|
||||||
<div class="d-flex align-items-center mb-1 text-muted fw-bold" style="font-size: 10px">
|
|
||||||
<i class="bi bi-filter me-1"></i> LOCAL FILTER
|
|
||||||
</div>
|
|
||||||
<input type="text" class="form-control form-control-sm" placeholder="Search..."
|
|
||||||
$bind="this.state.filterConfig[this.state.activeFieldId]"
|
|
||||||
$onkeydown="if(event.key === 'Enter') this.applySortFilter()">
|
|
||||||
</div>
|
</div>
|
||||||
<button class="list-group-item list-group-item-action py-2 text-danger d-flex align-items-center border-top" $onclick="this.clearColumnSettings()">
|
|
||||||
<i class="bi bi-x-circle me-2"></i> Clear Settings
|
<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;">
|
||||||
</button>
|
<template $each="this.state?.activeModes || []" as="m">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
|
<div $if="this.state?.activeModes?.length" 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>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
|
||||||
<div class="dt-editor-overlay dt-editor-container" style="display: none" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
<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>
|
||||||
<AutoForm inline class="h-100 w-100" $onsubmit="thisNode.closest('DataTable').hideEditor(true)"/>
|
<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>
|
|
||||||
|
|
||||||
<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="d-flex align-items-center gap-2">
|
|
||||||
<span $text="(this.state?.selectedRowCount || 0) + ' / ' + (this.state?.list?.length || 0)"></span>
|
|
||||||
<button class="btn btn-xs btn-link text-danger p-0 border-0" title="Delete Selected"
|
|
||||||
$if="this.state?.selectedRowCount" $onclick="this.deleteSelected()">
|
|
||||||
<i class="bi bi-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
`), Util.makeDom(/*html*/`
|
`), Util.makeDom(/*html*/`
|
||||||
<style>
|
<style>
|
||||||
DataTable { display: block; }
|
DataTable { display: block; }
|
||||||
.dt-root { font-size: 0.875rem; }
|
.dt-root { font-size: 0.875rem; }
|
||||||
.dt-row {
|
.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; }
|
||||||
display: grid;
|
.dt-header-row { background-color: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); }
|
||||||
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); }
|
|
||||||
.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-body-row:hover { background-color: var(--bs-secondary-bg) !important; }
|
.dt-body-row:hover { background-color: var(--bs-secondary-bg) !important; }
|
||||||
.dt-cell-selected { background-color: var(--bs-primary-bg-subtle) !important; }
|
|
||||||
.header-cell .header-menu-btn { opacity: 0; transition: opacity 0.2s; }
|
.header-cell .header-menu-btn { opacity: 0; transition: opacity 0.2s; }
|
||||||
.header-cell:hover .header-menu-btn { opacity: 1; }
|
.header-cell:hover .header-menu-btn { opacity: 1; }
|
||||||
.dt-column-menu { box-shadow: 0 4px 12px rgba(0,0,0,0.15) !important; }
|
.dt-column-menu { background-color: var(--bs-body-bg); border: 1px solid var(--bs-border-color); box-shadow: 0 10px 40px rgba(0,0,0,0.2) !important; z-index: 2100 !important; }
|
||||||
.dt-column-menu .list-group-item { border: none; cursor: pointer; }
|
|
||||||
.dt-column-menu .list-group-item:hover { background-color: var(--bs-light); }
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
.dt-editor-overlay .form-control { border: none !important; border-radius: 0 !important; height: 100% !important; padding: 0 8px !important; }
|
|
||||||
.btn-xs { padding: 1px 5px; line-height: 1.5; }
|
.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>
|
</style>
|
||||||
`))
|
`))
|
||||||
|
|
||||||
if (typeof document !== 'undefined') RefreshState(document.documentElement)
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user