2026-05-17 17:03:21 +08:00
|
|
|
import { Component, NewState, Util, RefreshState } from '@web/state'
|
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-22 20:32:26 +08:00
|
|
|
selectedRowCount: 0,
|
|
|
|
|
// --- Editing State ---
|
|
|
|
|
editing: null // { row, field, node, style }
|
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-22 20:32:26 +08:00
|
|
|
if (state.editing) {
|
|
|
|
|
// Close editor on scroll to prevent floating away
|
|
|
|
|
container.cancelEdit();
|
|
|
|
|
}
|
2026-05-22 19:16:45 +08:00
|
|
|
selection.applySelectionUI();
|
|
|
|
|
});
|
2026-05-22 12:32:50 +08:00
|
|
|
|
|
|
|
|
container.refresh = () => {
|
2026-05-22 19:16:45 +08:00
|
|
|
const frameStart = perf.startFrame();
|
|
|
|
|
scroll.refresh();
|
|
|
|
|
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-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-22 19:16:45 +08:00
|
|
|
// Expose selection methods to template
|
|
|
|
|
container.startSelect = selection.startSelect;
|
|
|
|
|
container.updateSelect = selection.updateSelect;
|
|
|
|
|
container.deleteSelected = selection.deleteSelected;
|
|
|
|
|
|
2026-05-22 20:32:26 +08:00
|
|
|
// --- Editing Logic ---
|
|
|
|
|
container.editCell = (row, field, cellNode) => {
|
|
|
|
|
const body = container.querySelector('.dt-body');
|
|
|
|
|
const rect = cellNode.getBoundingClientRect();
|
|
|
|
|
const bodyRect = body.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
// Ensure row is a State object for reliable binding
|
|
|
|
|
const listIdx = state.list.indexOf(row);
|
|
|
|
|
let targetRow = row;
|
|
|
|
|
if (listIdx !== -1 && !row.__watch) {
|
|
|
|
|
targetRow = NewState(row);
|
|
|
|
|
state.list[listIdx] = targetRow;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
state.editing = {
|
|
|
|
|
row: targetRow,
|
|
|
|
|
field,
|
|
|
|
|
node: cellNode,
|
|
|
|
|
style: `left:${rect.left - bodyRect.left}px; top:${rect.top - bodyRect.top}px; width:${rect.width}px; height:${rect.height}px;`
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Optimization: $. attributes are not reactive. We must manually update the editor
|
|
|
|
|
// if it's already in the DOM, or the next frame after $if renders it.
|
|
|
|
|
const syncEditor = () => {
|
|
|
|
|
const editor = container.querySelector('.dt-editor-container AutoForm');
|
|
|
|
|
if (editor) {
|
|
|
|
|
editor.state.schema = [{ ...field, name: field.id, label: '' }];
|
|
|
|
|
editor.data = targetRow;
|
|
|
|
|
RefreshState(editor);
|
|
|
|
|
const el = editor.querySelector('.form-control, .form-select, .form-check-input');
|
|
|
|
|
if (el) el.focus();
|
|
|
|
|
} else {
|
|
|
|
|
requestAnimationFrame(syncEditor);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
syncEditor();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
container.finishEdit = () => {
|
|
|
|
|
const node = state.editing?.node;
|
|
|
|
|
state.editing = null;
|
|
|
|
|
if (node) RefreshState(node);
|
|
|
|
|
container.focus(); // Return focus to table
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
container.cancelEdit = () => {
|
|
|
|
|
state.editing = null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-05-22 19:16:45 +08:00
|
|
|
// Copy & Paste (simplified)
|
|
|
|
|
const escapeTSV = val => {
|
|
|
|
|
const str = String(val ?? '')
|
|
|
|
|
return (str.includes('\t') || str.includes('\n') || str.includes('"')) ? '"' + str.replace(/"/g, '""') + '"' : str
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
container.copy = async () => {
|
|
|
|
|
const bounds = selection.getSelectionBounds();
|
|
|
|
|
if (!bounds) return;
|
|
|
|
|
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map(row =>
|
|
|
|
|
state.fields.slice(bounds.minCol, bounds.maxCol + 1).map(f => escapeTSV(row[f.id])).join('\t')
|
|
|
|
|
).join('\n')
|
|
|
|
|
await navigator.clipboard.writeText(text)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
container.addEventListener('keydown', e => {
|
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
|
|
|
if (e.key === 'c') { e.preventDefault(); container.copy() }
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const onGlobalMouseUp = () => selection.endSelect()
|
|
|
|
|
const onGlobalMouseDown = e => {
|
|
|
|
|
if (!container.contains(e.target)) selection.clearAllActive()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.addEventListener('mouseup', onGlobalMouseUp)
|
|
|
|
|
document.addEventListener('mousedown', onGlobalMouseDown)
|
|
|
|
|
|
|
|
|
|
container._onUnload = () => {
|
|
|
|
|
document.removeEventListener('mousedown', onGlobalMouseDown)
|
|
|
|
|
window.removeEventListener('mouseup', onGlobalMouseUp)
|
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">
|
|
|
|
|
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()" 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">
|
|
|
|
|
<div $each="this.state.fields" class="dt-cell border-end px-2 d-flex align-items-center" $style="'width: var(--w-' + item.id + ', ' + (item.width || 150) + 'px); min-width: var(--w-' + item.id + ', ' + (item.width || 150) + 'px)'">
|
|
|
|
|
<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">
|
|
|
|
|
<div $if="(this.state?.prevHeight || 0) > 0" $style="'height:' + this.state.prevHeight + 'px;'" class="flex-shrink-0"></div>
|
2026-05-22 19:39:03 +08:00
|
|
|
<div $each="this.state?._renderedList" key="id" index="rIdx" class="dt-row dt-body-row border-bottom bg-white">
|
2026-05-22 19:16:45 +08:00
|
|
|
<div $each="this.state.fields" as="f" index="fIdx"
|
|
|
|
|
class="dt-cell border-end px-2 d-flex align-items-center"
|
|
|
|
|
$onmousedown="this.startSelect(rIdx + this.state._listStartIndex, fIdx, event)"
|
|
|
|
|
$onmouseenter="this.updateSelect(rIdx + this.state._listStartIndex, fIdx)"
|
2026-05-22 20:32:26 +08:00
|
|
|
$ondblclick="this.editCell(item, f, thisNode)"
|
2026-05-22 19:16:45 +08:00
|
|
|
$style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px)'">
|
2026-05-22 20:32:26 +08:00
|
|
|
<div $if="f.type === 'switch'" class="form-check form-switch d-flex justify-content-center w-100 m-0 p-0">
|
|
|
|
|
<input class="form-check-input m-0" type="checkbox" $checked="item[f.id]" disabled>
|
|
|
|
|
</div>
|
|
|
|
|
<span $if="f.type !== 'switch'" $text="item[f.id] ?? ''" class="text-truncate"></span>
|
2026-05-22 19:16:45 +08:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div $if="(this.state?.postHeight || 0) > 0" $style="'height:' + this.state.postHeight + 'px;'" class="flex-shrink-0"></div>
|
2026-05-22 20:32:26 +08:00
|
|
|
|
|
|
|
|
<div $if="this.state.editing" class="dt-editor-container" $style="this.state.editing.style" $onclick="event.stopPropagation()" $onmousedown="event.stopPropagation()">
|
|
|
|
|
<AutoForm inline class="h-100 w-100" $onkeydown="(event.key === 'Enter' && thisNode.closest('DataTable').state.editing.field.type !== 'textarea') && thisNode.closest('DataTable').finishEdit(); event.key === 'Escape' && thisNode.closest('DataTable').cancelEdit()"/>
|
|
|
|
|
</div>
|
2026-05-22 19:16:45 +08:00
|
|
|
</div>
|
2026-05-17 17:03:21 +08:00
|
|
|
</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">
|
|
|
|
|
Total: <span $text="this.state.list.length"></span> |
|
|
|
|
|
Selected Rows: <span $text="this.state.selectedRowCount"></span>
|
2026-05-17 17:03:21 +08:00
|
|
|
</div>
|
2026-05-22 19:16:45 +08:00
|
|
|
<button class="btn btn-xs btn-outline-danger py-0 px-2" style="font-size: 11px" $disabled="this.state.selectedRowCount === 0" $onclick="this.deleteSelected()">
|
|
|
|
|
<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-22 12:32:50 +08:00
|
|
|
display: flex;
|
|
|
|
|
flex-direction: row;
|
|
|
|
|
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-22 20:32:26 +08:00
|
|
|
.dt-editor-container {
|
|
|
|
|
position: absolute;
|
|
|
|
|
z-index: 100;
|
|
|
|
|
background: var(--bs-body-bg);
|
|
|
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
|
|
|
border: 1px solid var(--bs-primary);
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
}
|
|
|
|
|
.dt-editor-container .auto-form-root, .dt-editor-container form {
|
|
|
|
|
height: 100%;
|
|
|
|
|
width: 100%;
|
|
|
|
|
}
|
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)
|