diff --git a/src/index.js b/src/index.js index 6519be7..ce804f2 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,21 @@ import { createPerfMonitor } from './perf.js' import { createScrollManager } from './scroll.js' import { createSelectionManager } from './selection.js' +// Static configuration maps +const MODE_MAP = { + text: ['contains', 'equals', 'starts', 'ends'], + textarea: ['contains', 'equals', 'starts', 'ends'], + number: ['=', '>', '<', 'between'], + date: ['=', '>', '<', 'between'], + select: ['contains', 'equals'], + TagsInput: ['contains', 'equals', 'starts', 'ends'] +}; + +const MODE_ICONS = { + 'contains': 'bi-search', 'equals': 'bi-distribute-vertical', 'starts': 'bi-align-start', 'ends': 'bi-align-end', + '=': 'bi-calculator', '>': 'bi-chevron-right', '<': 'bi-chevron-left', 'between': 'bi-arrows-expand' +}; + Component.register('DataTable', container => { if (!container.state) container.state = NewState({}) const state = container.state @@ -14,8 +29,11 @@ Component.register('DataTable', container => { selectedRowCount: 0, _originalList: [], sortConfig: { fieldId: null, direction: null }, - filterConfig: {}, + filterConfig: {}, // fieldId -> { mode, value, value2, selectedValues: [] } activeFieldId: null, + activeField: null, + activeModes: [], + _columnStats: {}, _internalUpdate: false, _appliedHash: '', _fieldsDirty: false, @@ -24,64 +42,30 @@ Component.register('DataTable', container => { const perf = createPerfMonitor(); state.perf = perf.stats; - const selection = createSelectionManager(container, state); + const scroll = createScrollManager(container, state, () => selection.applySelectionUI()); - const scroll = createScrollManager(container, state, (renderedCount, isLayoutChange) => { - selection.applySelectionUI(); - }); + const menuNode = container.querySelector('.dt-column-menu'); + if (menuNode) menuNode._thisObj = container; - let _prevSpacer, _postSpacer, _editorOverlay; + container.onColumnResizing = (field, e) => container.style.setProperty(`--w-${field.id}`, e.detail.newSize + 'px'); + container.onColumnResize = (field, e) => { + const idx = state.fields.findIndex(f => f.id === field.id); + if (idx !== -1) { state.fields[idx].width = e.detail.newSize; state.fields = [...state.fields]; } + }; + + let _editorOverlay, currentEditingNode = null; container.format = (val, field) => { if (field.formatter) return field.formatter(val, field); - if (typeof val === 'string') return val; - if (val === null || val === undefined) return ''; - return typeof val === 'object' ? JSON.stringify(val) : String(val); + return val == null ? '' : (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 = () => { - perf.onScroll(); - container.refresh(); - - if (_scrollRaf) return; - _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'; - } - } - }); + perf.onScroll(); scroll.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 (post) { post.style.height = (state.postHeight || 0) + 'px'; post.style.display = state.postHeight > 0 ? 'block' : 'none'; } }; container.applySortFilter = (options = {}) => { @@ -89,74 +73,79 @@ Component.register('DataTable', container => { const targetFilters = { ...state.filterConfig, ...(options.filters || {}) }; const targetSort = options.sort !== undefined ? (options.sort ? { fieldId: state.activeFieldId, direction: options.sort } : { fieldId: null, direction: null }) : state.sortConfig; const currentHash = JSON.stringify({ s: targetSort, f: targetFilters }); - if (state._appliedHash === currentHash && !options.force) return; - - state._internalUpdate = true; - const startTime = performance.now(); - let list = (options.force) ? state.list : [...(state._originalList || [])]; + if (state._appliedHash === currentHash && !options.force) return; - if (!options.force) { - Object.keys(targetFilters).forEach(fieldId => { - const val = targetFilters[fieldId]; - if (val) { - const lowerVal = String(val).toLowerCase(); - list = list.filter(item => String(item[fieldId] ?? '').toLowerCase().includes(lowerVal)); + state._internalUpdate = true; + let list = [...(state._originalList || [])]; + Object.keys(targetFilters).forEach(fieldId => { + const filter = targetFilters[fieldId]; + if (!filter) return; + const { mode = 'contains', value, value2, selectedValues } = filter; + if (selectedValues?.length > 0) { list = list.filter(item => selectedValues.includes(String(item[fieldId] ?? ''))); return; } + if (value === '' || value == null) return; + const lowV = String(value).toLowerCase(), n1 = Number(value), n2 = Number(value2); + list = list.filter(item => { + const iv = item[fieldId], sv = String(iv ?? '').toLowerCase(); + switch (mode) { + case 'contains': return sv.includes(lowV); + case 'equals': return sv === lowV; + case 'starts': return sv.startsWith(lowV); + case 'ends': return sv.endsWith(lowV); + case '>': return Number(iv) > n1; + case '<': return Number(iv) < n1; + case '=': return Number(iv) === n1; + case 'between': return Number(iv) >= n1 && Number(iv) <= n2; + default: return sv.includes(lowV); } }); - if (targetSort && 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) => { e.stopPropagation(); - const btn = e.currentTarget; - const rect = btn.getBoundingClientRect(); - const rootRect = container.getBoundingClientRect(); - const menu = container.querySelector('.dt-column-menu'); - state.activeFieldId = field.id; + const btn = e.currentTarget, menu = container.querySelector('.dt-column-menu'); + const type = field.type || 'text'; + state.activeModes = MODE_MAP[type] || (['boolean', 'switch', 'checkbox', 'radio'].includes(type) ? [] : MODE_MAP.text); + if (!state.filterConfig[field.id]) { + state.filterConfig[field.id] = { mode: state.activeModes[0] || 'contains', value: '', selectedValues: [] }; + } + state.activeField = field; state.activeFieldId = field.id; menu.style.display = 'block'; - 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'; + const onGlobalClick = (ev) => { - if (menu.contains(ev.target)) return; - if (!btn.contains(ev.target)) { - container.hideColumnMenu(); - container.applySortFilter(); - document.removeEventListener('mousedown', onGlobalClick); - } + if (menu.contains(ev.target) || btn.contains(ev.target)) return; + container.hideColumnMenu(); container.applySortFilter(); + document.removeEventListener('mousedown', onGlobalClick); }; document.addEventListener('mousedown', onGlobalClick); - RefreshState(menu); - setTimeout(() => { - const input = menu.querySelector('input'); - if (input) input.focus(); - }, 50); + setTimeout(() => menu.querySelector('input')?.focus(), 50); + }; + + container.toggleSelectedValue = (val) => { + const filter = state.filterConfig[state.activeFieldId]; + if (!filter) return; + const idx = filter.selectedValues.indexOf(val); + if (idx === -1) filter.selectedValues.push(val); else filter.selectedValues.splice(idx, 1); + state.filterConfig = { ...state.filterConfig }; + container.applySortFilter(); + }; + + container.filterOnlyThis = (val) => { + state.filterConfig[state.activeFieldId] = { mode: 'contains', value: '', selectedValues: [String(val)] }; + state.filterConfig = { ...state.filterConfig }; + container.applySortFilter(); }; container.hideColumnMenu = () => { @@ -164,178 +153,141 @@ Component.register('DataTable', container => { if (menu) menu.style.display = 'none'; }; - container.setSort = (direction) => { - container.applySortFilter({ sort: direction }); + container.setSort = (dir) => { + const newDir = state.sortConfig.direction === dir && state.sortConfig.fieldId === state.activeFieldId ? null : dir; + container.applySortFilter({ sort: newDir }); }; container.clearColumnSettings = () => { - const filters = { ...state.filterConfig }; - delete filters[state.activeFieldId]; - state.filterConfig = filters; - container.applySortFilter({ sort: null }); - 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 }; - } + if (state.activeFieldId) { + delete state.filterConfig[state.activeFieldId]; + state.filterConfig = { ...state.filterConfig }; + container.applySortFilter(); } }; - container.onMainMouseDown = e => { - const cell = e.target.closest('.dt-cell'); - if (!cell) return; - const row = cell.closest('.dt-row'); - if (!row || row.classList.contains('dt-header-row')) return; - 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.startSelect(rIdx + state._listStartIndex, fIdx, e); - }; - - 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); + container._initRow = (rowNode) => { + const row = rowNode._ref?.item; + if (row && row._editingF === undefined) { + Object.defineProperty(row, '_editingF', { set: (v) => { if (v === null) container.hideEditor(true); }, configurable: true }); + } + Array.from(rowNode.children).forEach(cell => { + const fIdx = parseInt(cell.dataset.fidx); + if (!isNaN(fIdx)) cell._refExt = { f: state.fields[fIdx], fIdx: fIdx }; + }); }; state.__watch('fields', fields => { if (!fields) return; state._fieldsDirty = true; - const gridTemplate = 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-grid-template', gridTemplate); - container.style.setProperty('--dt-row-width', totalWidth + 'px'); - }) + container.style.setProperty('--dt-grid-template', fields.map(f => `var(--w-${f.id}, ${f.width || 150}px)`).join(' ')); + container.style.setProperty('--dt-row-width', fields.reduce((sum, f) => sum + (f.width || 150), 0) + 'px'); + }); state.__watch('list', list => { if (state._fieldsDirty) { state._fieldsDirty = false; - const rowTemplate = container.querySelector('.dt-body template[index="rIdx"]'); - if (rowTemplate) { - const fieldTemplate = rowTemplate.content.querySelector('template[as="f"]'); - if (fieldTemplate) { - // 1. Capture the master copy of cell nodes once - if (!state._masterCellNodes) { - 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); - }); - }); - } + const fieldTemplate = container.querySelector('.dt-body template[index="rIdx"]')?.content.querySelector('template[as="f"]'); + if (fieldTemplate) { + const masters = state._masterCellNodes || (state._masterCellNodes = Array.from(fieldTemplate.content.childNodes).map(n => n.cloneNode(true))); + fieldTemplate.removeAttribute('$each'); fieldTemplate.setAttribute('$if', 'true'); fieldTemplate.content.textContent = ''; + state.fields.forEach((f, fIdx) => masters.forEach(master => { + const clone = master.cloneNode(true); if (clone.nodeType === 1) clone.dataset.fidx = fIdx; fieldTemplate.content.appendChild(clone); + })); } } - if (!state._internalUpdate) state._originalList = [...(list || [])]; - scroll.init(); - scroll.reset(list); - }) + if (!state._internalUpdate) { + state._originalList = [...(list || [])]; + setTimeout(() => { + const stats = {}; + state.fields.forEach(f => { + const counts = {}; + state._originalList.forEach(item => { const val = item[f.id], key = (val == null || val === '') ? '' : String(val); counts[key] = (counts[key] || 0) + 1; }); + stats[f.id] = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([val, count]) => ({ val, count })); + }); + state._columnStats = stats; + }, 200); + } + scroll.init(); scroll.reset(list); + }); - let currentEditingNode = null; container.editCell = (row, field, cellNode) => { - const overlay = container.querySelector('.dt-editor-overlay'); - const editor = overlay.querySelector('AutoForm'); - const rect = cellNode.getBoundingClientRect(); - const rootRect = container.getBoundingClientRect(); + const overlay = container.querySelector('.dt-editor-overlay'), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect(); currentEditingNode = cellNode; - let minW = rect.width; - if (field.type === 'textarea' || field.type === 'TagsInput') minW = Math.max(rect.width, 300); - else if (field.type === 'radio') minW = Math.max(rect.width, 240); - overlay.style.display = 'flex'; - overlay.style.left = (rect.left - rootRect.left) + 'px'; - overlay.style.top = (rect.top - rootRect.top) + 'px'; - overlay.style.width = minW + 'px'; - overlay.style.height = (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); + Object.assign(overlay.style, { + display: 'flex', left: (rect.left - rootRect.left) + 'px', top: (rect.top - rootRect.top) + 'px', + width: (field.type === 'textarea' || field.type === 'TagsInput' ? Math.max(rect.width, 300) : rect.width) + 'px', + height: (field.type === 'textarea' || field.type === 'TagsInput' ? 'auto' : rect.height + 'px') + }); + State.editingSchema = [{ ...field, name: field.id, label: '' }]; State.editingData = row; + setTimeout(() => overlay.querySelector('input, textarea, select, .form-control')?.focus(), 30); }; container.hideEditor = (save = true) => { if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay'); if (!_editorOverlay || _editorOverlay.style.display === 'none') return; - _editorOverlay.style.display = 'none'; - if (save && currentEditingNode) RefreshState(currentEditingNode); - State.editingSchema = State.editingData = currentEditingNode = null; + if (save) { + const input = _editorOverlay.querySelector('input:focus, select:focus, textarea:focus'); + if (input) input.dispatchEvent(new Event(input.type === 'number' || input.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true })); + if (currentEditingNode) RefreshState(currentEditingNode); + } + _editorOverlay.style.display = 'none'; State.editingSchema = State.editingData = currentEditingNode = null; container.focus(); }; - container.startSelect = selection.startSelect; - container.updateSelect = selection.updateSelect; - container.deleteSelected = selection.deleteSelected; + container.onMainMouseDown = e => { + const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row'); + if (!row || row.classList.contains('dt-header-row')) return; + const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.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 => { - const overlay = container.querySelector('.dt-editor-overlay'); - if (overlay && overlay.style.display !== 'none' && !overlay.contains(e.target)) container.hideEditor(true); - if (!container.contains(e.target) && !overlay?.contains(e.target)) selection.clearAllActive(); - } + container.onMainMouseOver = e => { + if (state.isSelecting) { + const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row'); + if (row && !row.classList.contains('dt-header-row')) { + const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.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); - document.addEventListener('mousedown', onGlobalMouseDown); - container._onUnload = () => { - document.removeEventListener('mousedown', onGlobalMouseDown); - window.removeEventListener('mouseup', selection.endSelect); - } + document.addEventListener('mousedown', e => { + const overlay = container.querySelector('.dt-editor-overlay'); + if (overlay?.style.display !== 'none' && !overlay.contains(e.target)) container.hideEditor(true); + if (!container.contains(e.target) && !overlay?.contains(e.target)) selection.clearAllActive(); + }); + + // Exposure for templates + state._MODE_ICONS = MODE_ICONS; }, Util.makeDom(/*html*/` -
+
-
+
@@ -344,82 +296,69 @@ Component.register('DataTable', container => {
-