dataTable/src/index.js

155 lines
5.5 KiB
JavaScript

import { Component, NewState, Util, RefreshState } from '@web/state'
import { createPerfMonitor } from './perf.js'
import { createScrollManager } from './scroll.js'
import { createSelectionManager } from './selection.js'
Component.register('DataTable', container => {
if (!container.state) container.state = NewState({})
const state = container.state
Object.assign(state, {
list: [], fields: [], _renderedList: [],
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
selectedRowCount: 0
})
const perf = createPerfMonitor();
state.perf = perf.stats;
const selection = createSelectionManager(container, state);
const scroll = createScrollManager(container, state, (renderedCount) => {
selection.applySelectionUI();
});
container.refresh = () => {
const frameStart = perf.startFrame();
scroll.refresh();
perf.endFrame(frameStart, state._renderedList.length);
};
container.onScroll = () => {
perf.onScroll();
container.refresh();
};
container.onItemUpdate = (index, node) => scroll.updateRowHeight(index, node);
state.__watch('list', list => {
scroll.init();
scroll.reset(list);
})
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)
})
// Expose selection methods to template
container.startSelect = selection.startSelect;
container.updateSelect = selection.updateSelect;
container.deleteSelected = selection.deleteSelected;
// 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)
}
}, 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()" 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>
</div>
</div>
<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>
<div $each="this.state?._renderedList" key="id" index="rIdx" class="dt-row dt-body-row border-bottom bg-white" $onupdate="this.onItemUpdate(rIdx, thisNode)">
<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)"
$style="'width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px); min-width: var(--w-' + f.id + ', ' + (f.width || 150) + 'px)'">
<span $text="item[f.id] ?? ''" class="text-truncate"></span>
</div>
</div>
<div $if="(this.state?.postHeight || 0) > 0" $style="'height:' + this.state.postHeight + 'px;'" class="flex-shrink-0"></div>
</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="flex-grow-1">
Total: <span $text="this.state.list.length"></span> |
Selected Rows: <span $text="this.state.selectedRowCount"></span>
</div>
<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>
</div>
</div>
`), Util.makeDom(/*html*/`
<style>
DataTable { display: block; }
.dt-root { font-size: 0.875rem; }
.dt-row {
display: flex;
flex-direction: row;
width: max-content;
min-width: 100%;
height: 40px;
}
.dt-header-row {
background-color: var(--bs-tertiary-bg);
}
.dt-cell {
background: inherit;
white-space: nowrap;
flex-shrink: 0;
}
.dt-body-row:hover {
background-color: var(--bs-secondary-bg) !important;
}
.dt-cell-selected {
background-color: var(--bs-primary-bg-subtle) !important;
}
.btn-xs {
padding: 1px 5px;
line-height: 1.5;
}
</style>
`))
if (typeof document !== 'undefined') RefreshState(document.documentElement)