dataTable/src/index.js

426 lines
17 KiB
JavaScript
Raw Normal View History

2026-05-17 17:03:21 +08:00
import { Component, NewState, Util, RefreshState } from '@web/state'
import { State } from '@web/base'
import { createPerfMonitor } from './perf.js'
import { createScrollManager } from './scroll.js'
import { createSelectionManager } from './selection.js'
2026-05-17 17:03:21 +08:00
Component.register('DataTable', container => {
if (!container.state) container.state = NewState({})
2026-05-17 17:03:21 +08:00
const state = container.state
Object.assign(state, {
list: [], fields: [], _renderedList: [],
2026-05-17 17:03:21 +08:00
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
selectedRowCount: 0,
_originalList: [],
sortConfig: { fieldId: null, direction: null },
filterConfig: {},
activeFieldId: null,
_internalUpdate: false,
_appliedHash: '',
_fieldsDirty: false,
_masterCellNodes: null
2026-05-17 17:03:21 +08:00
})
const perf = createPerfMonitor();
state.perf = perf.stats;
const selection = createSelectionManager(container, state);
const scroll = createScrollManager(container, state, (renderedCount, isLayoutChange) => {
selection.applySelectionUI();
});
let _prevSpacer, _postSpacer, _editorOverlay;
container.format = (val, field) => {
if (field.formatter) return field.formatter(val, field);
if (typeof val === 'string') return val;
if (val === null || val === undefined) return '';
return typeof val === 'object' ? JSON.stringify(val) : String(val);
};
container.refreshNode = (node) => RefreshState(node);
container.refresh = () => {
const frameStart = perf.startFrame();
scroll.refresh();
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);
};
2026-05-17 17:03:21 +08:00
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';
}
}
});
};
container.applySortFilter = (options = {}) => {
if (state._internalUpdate) return;
const targetFilters = { ...state.filterConfig, ...(options.filters || {}) };
const targetSort = options.sort !== undefined ? (options.sort ? { fieldId: state.activeFieldId, direction: options.sort } : { fieldId: null, direction: null }) : state.sortConfig;
const currentHash = JSON.stringify({ s: targetSort, f: targetFilters });
if (state._appliedHash === currentHash && !options.force) return;
state._internalUpdate = true;
const startTime = performance.now();
let list = (options.force) ? state.list : [...(state._originalList || [])];
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));
}
});
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;
};
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;
menu.style.display = 'block';
menu.style.left = (rect.right - rootRect.left - 180) + '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);
}
};
document.addEventListener('mousedown', onGlobalClick);
RefreshState(menu);
setTimeout(() => {
const input = menu.querySelector('input');
if (input) input.focus();
}, 50);
};
container.hideColumnMenu = () => {
const menu = container.querySelector('.dt-column-menu');
if (menu) menu.style.display = 'none';
};
container.setSort = (direction) => {
container.applySortFilter({ sort: direction });
};
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 };
}
}
};
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);
};
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');
})
2026-05-17 17:03:21 +08:00
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);
});
});
}
}
}
if (!state._internalUpdate) state._originalList = [...(list || [])];
scroll.init();
scroll.reset(list);
2026-05-17 17:03:21 +08:00
})
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();
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);
};
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;
container.focus();
};
container.startSelect = selection.startSelect;
container.updateSelect = selection.updateSelect;
container.deleteSelected = selection.deleteSelected;
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();
}
window.addEventListener('mouseup', selection.endSelect);
document.addEventListener('mousedown', onGlobalMouseDown);
container._onUnload = () => {
document.removeEventListener('mousedown', onGlobalMouseDown);
window.removeEventListener('mouseup', selection.endSelect);
2026-05-17 17:03:21 +08:00
}
}, 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-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
$onmousedown="this.onMainMouseDown(event)"
$onmouseover="this.onMainMouseOver(event)"
$ondblclick="this.onMainDblClick(event)"
style="overflow-anchor:none; min-height: 0">
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
<div class="dt-row dt-header-row fw-bold text-muted small">
<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 class="d-flex align-items-center overflow-hidden">
<i $if="this.state?.sortConfig?.fieldId === item.id"
$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"></span>
</div>
<button class="btn btn-xs btn-link text-muted p-0 border-0 header-menu-btn" $onclick="this.showColumnMenu(item, event)">
<i class="bi bi-chevron-down"></i>
</button>
</div>
</template>
</div>
2026-05-17 17:03:21 +08:00
</div>
<div class="dt-body" style="position:relative">
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
<template $each="this.state?._renderedList || []" key="id" index="rIdx">
<div class="dt-row dt-body-row border-bottom bg-white" $.="this._initRow(thisNode)">
<template $each="this.state?.fields || []" as="f" index="fIdx">
<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>
</template>
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
</div>
2026-05-17 17:03:21 +08:00
</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="list-group list-group-flush">
<button class="list-group-item list-group-item-action py-2 d-flex align-items-center" $onclick="this.setSort('asc')">
<i class="bi bi-sort-alpha-down me-2"></i> Sort Ascending
</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>
<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
</button>
</div>
</div>
<div class="dt-editor-overlay dt-editor-container" style="display: none" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
<AutoForm inline class="h-100 w-100" $onsubmit="thisNode.closest('DataTable').hideEditor(true)"/>
</div>
<div class="dt-footer border-top bg-light d-flex align-items-center px-2 py-1 small text-muted" style="height:32px">
<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>
2026-05-17 17:03:21 +08:00
</div>
</div>
</div>
`), Util.makeDom(/*html*/`
<style>
DataTable { display: block; }
.dt-root { font-size: 0.875rem; }
.dt-row {
display: grid;
grid-template-columns: var(--dt-grid-template);
width: 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-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:hover .header-menu-btn { opacity: 1; }
.dt-column-menu { box-shadow: 0 4px 12px rgba(0,0,0,0.15) !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; }
</style>
2026-05-17 17:03:21 +08:00
`))
if (typeof document !== 'undefined') RefreshState(document.documentElement)