2026-05-17 17:03:21 +08:00
|
|
|
import { Component, NewState, Util, RefreshState } from '@web/state'
|
2026-05-23 17:37:25 +08:00
|
|
|
import { State } from '@web/base'
|
2026-05-22 19:16:45 +08:00
|
|
|
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 => {
|
2026-05-17 20:11:20 +08:00
|
|
|
if (!container.state) container.state = NewState({})
|
2026-05-17 17:03:21 +08:00
|
|
|
const state = container.state
|
|
|
|
|
|
|
|
|
|
Object.assign(state, {
|
2026-05-22 12:32:50 +08:00
|
|
|
list: [], fields: [], _renderedList: [],
|
2026-05-17 17:03:21 +08:00
|
|
|
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
|
2026-05-23 17:37:25 +08:00
|
|
|
selectedRowCount: 0
|
2026-05-17 17:03:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-05-22 19:16:45 +08:00
|
|
|
const perf = createPerfMonitor();
|
|
|
|
|
state.perf = perf.stats;
|
|
|
|
|
|
|
|
|
|
const selection = createSelectionManager(container, state);
|
|
|
|
|
|
|
|
|
|
const scroll = createScrollManager(container, state, (renderedCount) => {
|
2026-05-23 17:37:25 +08:00
|
|
|
// Only hide editor if we actually scroll past its visibility range
|
|
|
|
|
// For now, simple hide for safety
|
|
|
|
|
container.hideEditor();
|
2026-05-22 19:16:45 +08:00
|
|
|
selection.applySelectionUI();
|
|
|
|
|
});
|
2026-05-22 12:32:50 +08:00
|
|
|
|
2026-05-23 17:37:25 +08:00
|
|
|
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);
|
|
|
|
|
|
2026-05-22 12:32:50 +08:00
|
|
|
container.refresh = () => {
|
2026-05-22 19:16:45 +08:00
|
|
|
const frameStart = perf.startFrame();
|
|
|
|
|
scroll.refresh();
|
2026-05-23 17:37:25 +08:00
|
|
|
|
|
|
|
|
// Manual DOM Sync for Spacers (Authorized optimization to avoid $if/$style overhead)
|
|
|
|
|
if (!_prevSpacer) _prevSpacer = container.querySelector('.dt-spacer-prev');
|
|
|
|
|
if (_prevSpacer) {
|
|
|
|
|
const h = state.prevHeight || 0;
|
|
|
|
|
_prevSpacer.style.height = h + 'px';
|
|
|
|
|
_prevSpacer.style.display = h > 0 ? 'block' : 'none';
|
|
|
|
|
}
|
|
|
|
|
if (!_postSpacer) _postSpacer = container.querySelector('.dt-spacer-post');
|
|
|
|
|
if (_postSpacer) {
|
|
|
|
|
const h = state.postHeight || 0;
|
|
|
|
|
_postSpacer.style.height = h + 'px';
|
|
|
|
|
_postSpacer.style.display = h > 0 ? 'block' : 'none';
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-22 19:16:45 +08:00
|
|
|
perf.endFrame(frameStart, state._renderedList.length);
|
|
|
|
|
};
|
2026-05-17 17:03:21 +08:00
|
|
|
|
2026-05-22 19:16:45 +08:00
|
|
|
container.onScroll = () => {
|
|
|
|
|
perf.onScroll();
|
|
|
|
|
container.refresh();
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-23 17:37:25 +08:00
|
|
|
// --- Optimized Event Delegation & DOM Interaction ---
|
|
|
|
|
container.onMainMouseDown = e => {
|
|
|
|
|
const cell = e.target.closest('.dt-cell');
|
|
|
|
|
if (!cell) return;
|
|
|
|
|
const row = cell.closest('.dt-row');
|
|
|
|
|
if (!row || row.classList.contains('dt-header-row')) return;
|
|
|
|
|
|
|
|
|
|
const fIdx = cell._ref?.fIdx ?? Array.from(row.children).indexOf(cell);
|
|
|
|
|
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
|
|
|
|
const absoluteRow = rIdx + state._listStartIndex;
|
|
|
|
|
container.startSelect(absoluteRow, fIdx, e);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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._ref?.fIdx ?? Array.from(row.children).indexOf(cell);
|
|
|
|
|
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
|
|
|
|
|
const absoluteRow = rIdx + state._listStartIndex;
|
|
|
|
|
container.updateSelect(absoluteRow, fIdx);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
container.onMainDblClick = e => {
|
|
|
|
|
const cell = e.target.closest('.dt-cell');
|
|
|
|
|
if (!cell) return;
|
|
|
|
|
const row = cell.closest('.dt-row');
|
|
|
|
|
if (!row || row.classList.contains('dt-header-row')) return;
|
|
|
|
|
|
|
|
|
|
const item = row._ref?.item;
|
|
|
|
|
const field = cell._ref?.f;
|
|
|
|
|
if (item && field) container.editCell(item, field, cell);
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-17 17:03:21 +08:00
|
|
|
state.__watch('list', list => {
|
2026-05-22 19:16:45 +08:00
|
|
|
scroll.init();
|
|
|
|
|
scroll.reset(list);
|
2026-05-17 17:03:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-05-22 12:32:50 +08:00
|
|
|
state.__watch('fields', fields => {
|
|
|
|
|
if (!fields) return
|
|
|
|
|
const gridTemplate = fields.map(f => `var(--w-${f.id}, ${f.width || 150}px)`).join(' ')
|
|
|
|
|
container.style.setProperty('--dt-grid-template', gridTemplate)
|
2026-05-17 17:03:21 +08:00
|
|
|
})
|
|
|
|
|
|
2026-05-23 17:37:25 +08:00
|
|
|
// --- Editing Logic (Manual Control, Permanent DOM) ---
|
|
|
|
|
let currentEditingNode = null;
|
2026-05-22 19:16:45 +08:00
|
|
|
|
2026-05-22 20:32:26 +08:00
|
|
|
container.editCell = (row, field, cellNode) => {
|
2026-05-23 17:37:25 +08:00
|
|
|
const overlay = container.querySelector('.dt-editor-overlay');
|
|
|
|
|
const editor = overlay.querySelector('AutoForm');
|
2026-05-22 20:32:26 +08:00
|
|
|
const rect = cellNode.getBoundingClientRect();
|
2026-05-23 17:37:25 +08:00
|
|
|
const rootRect = container.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
currentEditingNode = cellNode;
|
|
|
|
|
|
|
|
|
|
// 1. Precise Positioning with expansion and Z-index
|
|
|
|
|
let minW = rect.width;
|
|
|
|
|
if (field.type === 'textarea' || field.type === 'TagsInput') minW = Math.max(rect.width, 300);
|
|
|
|
|
else if (field.type === 'radio') minW = Math.max(rect.width, 240);
|
|
|
|
|
|
|
|
|
|
overlay.style.display = 'flex';
|
|
|
|
|
overlay.style.left = (rect.left - rootRect.left) + 'px';
|
|
|
|
|
overlay.style.top = (rect.top - rootRect.top) + 'px';
|
|
|
|
|
overlay.style.width = minW + 'px';
|
|
|
|
|
|
|
|
|
|
// Reset styles to prevent crosstalk from previous editor types
|
|
|
|
|
overlay.style.height = '';
|
|
|
|
|
overlay.style.minHeight = '';
|
|
|
|
|
overlay.style.alignItems = 'center';
|
|
|
|
|
|
|
|
|
|
if (field.type === 'textarea' || field.type === 'TagsInput') {
|
|
|
|
|
overlay.style.minHeight = (field.type === 'textarea' ? 150 : rect.height) + 'px';
|
|
|
|
|
overlay.style.height = 'auto';
|
|
|
|
|
overlay.style.alignItems = 'flex-start';
|
|
|
|
|
} else {
|
|
|
|
|
overlay.style.height = (rect.height) + 'px';
|
2026-05-22 20:32:26 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-23 17:37:25 +08:00
|
|
|
// 2. Direct Driver: Switch context
|
|
|
|
|
// Use global State for both schema and data to ensure atomic updates
|
|
|
|
|
State.editingSchema = [{ ...field, name: field.id, label: '' }];
|
|
|
|
|
State.editingData = row;
|
|
|
|
|
RefreshState(overlay);
|
2026-05-22 20:32:26 +08:00
|
|
|
|
2026-05-23 17:37:25 +08:00
|
|
|
// 3. Focus
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
const input = editor.querySelector('.form-control, .form-select, .form-check-input, input');
|
|
|
|
|
if (input) input.focus();
|
|
|
|
|
}, 30);
|
2026-05-22 20:32:26 +08:00
|
|
|
};
|
|
|
|
|
|
2026-05-23 17:37:25 +08:00
|
|
|
container.hideEditor = (save = true) => {
|
|
|
|
|
if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay');
|
|
|
|
|
if (!_editorOverlay || _editorOverlay.style.display === 'none') return;
|
2026-05-22 20:32:26 +08:00
|
|
|
|
2026-05-23 17:37:25 +08:00
|
|
|
_editorOverlay.style.display = 'none';
|
|
|
|
|
if (save && currentEditingNode) {
|
|
|
|
|
RefreshState(currentEditingNode);
|
|
|
|
|
}
|
|
|
|
|
State.editingSchema = null;
|
|
|
|
|
State.editingData = null;
|
|
|
|
|
currentEditingNode = null;
|
|
|
|
|
container.focus();
|
|
|
|
|
};
|
2026-05-22 19:16:45 +08:00
|
|
|
|
2026-05-23 17:37:25 +08:00
|
|
|
// --- Shared Logic ---
|
|
|
|
|
container.startSelect = selection.startSelect;
|
|
|
|
|
container.updateSelect = selection.updateSelect;
|
|
|
|
|
container.deleteSelected = selection.deleteSelected;
|
2026-05-22 19:16:45 +08:00
|
|
|
|
|
|
|
|
container.addEventListener('keydown', e => {
|
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
2026-05-23 17:37:25 +08:00
|
|
|
const k = e.key.toLowerCase();
|
|
|
|
|
if (k === 'c') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
selection.copy();
|
|
|
|
|
}
|
|
|
|
|
if (k === 'v') {
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
selection.paste();
|
|
|
|
|
}
|
2026-05-22 19:16:45 +08:00
|
|
|
}
|
2026-05-23 17:37:25 +08:00
|
|
|
if (e.key === 'Escape') container.hideEditor(false);
|
|
|
|
|
});
|
2026-05-22 19:16:45 +08:00
|
|
|
|
|
|
|
|
const onGlobalMouseDown = e => {
|
2026-05-23 17:37:25 +08:00
|
|
|
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();
|
2026-05-22 19:16:45 +08:00
|
|
|
}
|
2026-05-23 17:37:25 +08:00
|
|
|
|
|
|
|
|
window.addEventListener('mouseup', selection.endSelect);
|
|
|
|
|
document.addEventListener('mousedown', onGlobalMouseDown);
|
|
|
|
|
|
2026-05-22 19:16:45 +08:00
|
|
|
container._onUnload = () => {
|
|
|
|
|
document.removeEventListener('mousedown', onGlobalMouseDown)
|
2026-05-23 17:37:25 +08:00
|
|
|
window.removeEventListener('mouseup', selection.endSelect)
|
2026-05-17 17:03:21 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
}, Util.makeDom(/*html*/`
|
2026-05-22 19:16:45 +08:00
|
|
|
<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">
|
2026-05-23 17:37:25 +08:00
|
|
|
<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">
|
2026-05-22 19:16:45 +08:00
|
|
|
<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">
|
2026-05-23 17:37:25 +08:00
|
|
|
<div $each="this.state?.fields || []" class="dt-cell border-end px-2 d-flex align-items-center">
|
2026-05-22 19:16:45 +08:00
|
|
|
<span $text="item.name" class="text-truncate"></span>
|
|
|
|
|
</div>
|
2026-05-22 12:32:50 +08:00
|
|
|
</div>
|
2026-05-17 17:03:21 +08:00
|
|
|
</div>
|
2026-05-22 19:16:45 +08:00
|
|
|
<div class="dt-body" style="position:relative">
|
2026-05-23 17:37:25 +08:00
|
|
|
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
|
|
|
|
|
<div $each="this.state?._renderedList || []" key="id" index="rIdx" class="dt-row dt-body-row border-bottom bg-white">
|
|
|
|
|
<div $each="this.state?.fields || []" as="f" index="fIdx" class="dt-cell border-end px-2 d-flex align-items-center">
|
|
|
|
|
<span $text="this.format(item[f.id], f)" class="text-truncate"></span>
|
2026-05-22 19:16:45 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-23 17:37:25 +08:00
|
|
|
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
|
2026-05-22 19:16:45 +08:00
|
|
|
</div>
|
2026-05-17 17:03:21 +08:00
|
|
|
</div>
|
2026-05-23 17:37:25 +08:00
|
|
|
|
|
|
|
|
<!-- Shared Editor Overlay -->
|
|
|
|
|
<div class="dt-editor-overlay" style="display: none" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
|
|
|
|
<AutoForm inline class="h-100 w-100" $onsubmit="thisNode.closest('DataTable').hideEditor(true)"/>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-05-22 19:16:45 +08:00
|
|
|
<div class="dt-footer border-top bg-light d-flex align-items-center px-2 py-1 small text-muted" style="height:32px">
|
|
|
|
|
<div class="flex-grow-1">
|
2026-05-23 17:37:25 +08:00
|
|
|
Total: <span $text="this.state?.list?.length || 0"></span> |
|
|
|
|
|
Selected Rows: <span $text="this.state?.selectedRowCount || 0"></span>
|
2026-05-17 17:03:21 +08:00
|
|
|
</div>
|
2026-05-23 17:37:25 +08:00
|
|
|
<button class="btn btn-xs btn-outline-danger py-0 px-2" style="font-size: 11px" $disabled="!this.state?.selectedRowCount" $onclick="this.deleteSelected()">
|
2026-05-22 19:16:45 +08:00
|
|
|
<i class="bi bi-trash me-1"></i>Delete Selected
|
|
|
|
|
</button>
|
2026-05-17 17:03:21 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-05-22 12:32:50 +08:00
|
|
|
`), Util.makeDom(/*html*/`
|
|
|
|
|
<style>
|
|
|
|
|
DataTable { display: block; }
|
2026-05-22 19:16:45 +08:00
|
|
|
.dt-root { font-size: 0.875rem; }
|
|
|
|
|
.dt-row {
|
2026-05-23 17:37:25 +08:00
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: var(--dt-grid-template);
|
2026-05-22 12:32:50 +08:00
|
|
|
width: max-content;
|
|
|
|
|
min-width: 100%;
|
2026-05-22 19:16:45 +08:00
|
|
|
height: 40px;
|
|
|
|
|
}
|
|
|
|
|
.dt-header-row {
|
|
|
|
|
background-color: var(--bs-tertiary-bg);
|
2026-05-22 12:32:50 +08:00
|
|
|
}
|
|
|
|
|
.dt-cell {
|
|
|
|
|
background: inherit;
|
|
|
|
|
white-space: nowrap;
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
}
|
2026-05-22 19:16:45 +08:00
|
|
|
.dt-body-row:hover {
|
|
|
|
|
background-color: var(--bs-secondary-bg) !important;
|
|
|
|
|
}
|
|
|
|
|
.dt-cell-selected {
|
|
|
|
|
background-color: var(--bs-primary-bg-subtle) !important;
|
|
|
|
|
}
|
2026-05-23 17:37:25 +08:00
|
|
|
.dt-editor-overlay {
|
2026-05-22 20:32:26 +08:00
|
|
|
position: absolute;
|
2026-05-23 17:37:25 +08:00
|
|
|
z-index: 1000;
|
2026-05-22 20:32:26 +08:00
|
|
|
background: var(--bs-body-bg);
|
2026-05-23 17:37:25 +08:00
|
|
|
box-shadow: 0 4px 16px rgba(0,0,0,0.25);
|
2026-05-22 20:32:26 +08:00
|
|
|
border: 1px solid var(--bs-primary);
|
|
|
|
|
align-items: center;
|
2026-05-23 17:37:25 +08:00
|
|
|
overflow: visible;
|
|
|
|
|
transition: top 0.1s ease, left 0.1s ease, width 0.1s ease, height 0.1s ease, min-height 0.1s ease;
|
2026-05-22 20:32:26 +08:00
|
|
|
}
|
2026-05-23 17:37:25 +08:00
|
|
|
.dt-editor-overlay .auto-form-root, .dt-editor-overlay form {
|
2026-05-22 20:32:26 +08:00
|
|
|
height: 100%;
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
2026-05-23 17:37:25 +08:00
|
|
|
.dt-editor-overlay .form-control, .dt-editor-overlay .form-select {
|
|
|
|
|
border: none !important;
|
|
|
|
|
border-radius: 0 !important;
|
|
|
|
|
height: 100% !important;
|
|
|
|
|
width: 100% !important;
|
|
|
|
|
box-shadow: none !important;
|
|
|
|
|
padding: 0 8px !important;
|
|
|
|
|
}
|
|
|
|
|
.dt-editor-overlay .form-check.form-switch {
|
|
|
|
|
padding-left: 2.5em;
|
|
|
|
|
margin: 0;
|
|
|
|
|
height: 100%;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
2026-05-22 19:16:45 +08:00
|
|
|
.btn-xs {
|
|
|
|
|
padding: 1px 5px;
|
|
|
|
|
line-height: 1.5;
|
2026-05-22 12:32:50 +08:00
|
|
|
}
|
|
|
|
|
</style>
|
2026-05-17 17:03:21 +08:00
|
|
|
`))
|
2026-05-17 20:11:20 +08:00
|
|
|
|
|
|
|
|
if (typeof document !== 'undefined') RefreshState(document.documentElement)
|