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
2026-05-25 08:14:16 +08:00
// 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'
} ;
2026-05-28 17:07:51 +08:00
export const DataTable = {
_fieldTypes : new Map ( ) ,
registerFieldType : ( config ) => {
DataTable . _fieldTypes . set ( config . value , config ) ;
} ,
getFieldTypes : ( ) => Array . from ( DataTable . _fieldTypes . values ( ) )
} ;
// Register Built-in Types
DataTable . registerFieldType ( {
value : 'text' , label : '{#Text#}' , typeForDB : 'v4096' ,
schema : [ { name : 'placeholder' , label : 'Placeholder' , type : 'text' , if : 'this.data.user_type=="text"' } ]
} ) ;
DataTable . registerFieldType ( {
value : 'number' , label : '{#Number#}' , typeForDB : 'ff' ,
schema : [
{ name : 'decimals' , label : 'Decimals' , type : 'number' , setting : { min : 0 , max : 10 } , if : 'this.data.user_type=="number"' } ,
{ name : 'prefix' , label : 'Prefix (e.g. $)' , type : 'text' , if : 'this.data.user_type=="number"' } ,
{ name : 'suffix' , label : 'Suffix (e.g. %)' , type : 'text' , if : 'this.data.user_type=="number"' } ,
{ name : 'thousandSep' , label : 'Thousand Sep' , type : 'switch' , if : 'this.data.user_type=="number"' }
] ,
formatter : ( val , field ) => {
if ( val == null || val === '' ) return '' ;
let num = Number ( val ) ;
if ( isNaN ( num ) ) return val ;
const s = field . settings || { } ;
if ( s . decimals !== undefined ) num = num . toFixed ( s . decimals ) ;
let str = String ( num ) ;
if ( s . thousandSep ) {
const parts = str . split ( '.' ) ;
parts [ 0 ] = parts [ 0 ] . replace ( /\B(?=(\d{3})+(?!\d))/g , ',' ) ;
str = parts . join ( '.' ) ;
}
return ( s . prefix || '' ) + str + ( s . suffix || '' ) ;
}
} ) ;
DataTable . registerFieldType ( {
value : 'select' , label : '{#Single Select#}' , typeForDB : 'v1024' ,
schema : [ { name : 'options_str' , label : 'Options' , type : 'textarea' , setting : { rows : 3 , placeholder : 'Label:Value per line' } , if : 'this.data.user_type=="select"' } ] ,
formatter : ( val , field ) => {
if ( val == null || val === '' ) return '' ;
const opts = field . settings ? . options || [ ] ;
const opt = opts . find ( o => typeof o === 'object' ? o . value == val : o == val ) ;
return opt ? ( typeof opt === 'object' ? opt . label : opt ) : val ;
}
} ) ;
DataTable . registerFieldType ( {
value : 'checkbox' , label : '{#Multi Select#}' , typeForDB : 'v4096' ,
schema : [ { name : 'options_str' , label : 'Options' , type : 'textarea' , setting : { rows : 3 , placeholder : 'Label:Value per line' } , if : 'this.data.user_type=="checkbox"' } ] ,
formatter : ( val , field ) => {
if ( ! Array . isArray ( val ) ) return val == null ? '' : String ( val ) ;
const opts = field . settings ? . options || [ ] ;
return val . map ( v => {
const opt = opts . find ( o => typeof o === 'object' ? o . value == v : o == v ) ;
return opt ? ( typeof opt === 'object' ? opt . label : opt ) : v ;
} ) . join ( ', ' ) ;
}
} ) ;
DataTable . registerFieldType ( {
value : 'switch' , label : '{#Switch#}' , typeForDB : 'b' ,
schema : [
{ name : 'labelOn' , label : 'Label On' , type : 'text' , if : 'this.data.user_type=="switch"' } ,
{ name : 'labelOff' , label : 'Label Off' , type : 'text' , if : 'this.data.user_type=="switch"' }
] ,
formatter : ( val , field ) => {
const s = field . settings || { } ;
return val ? ( s . labelOn || 'Yes' ) : ( s . labelOff || 'No' ) ;
}
} ) ;
DataTable . registerFieldType ( {
value : 'datetime' , label : '{#DateTime#}' , typeForDB : 'dt' ,
schema : [ { name : 'format' , label : 'Format' , type : 'text' , setting : { placeholder : 'YYYY-MM-DD' } , if : 'this.data.user_type=="datetime"' } ]
} ) ;
DataTable . registerFieldType ( {
value : 'textarea' , label : '{#Long Text#}' , typeForDB : 't' ,
schema : [ { name : 'placeholder' , label : 'Placeholder' , type : 'text' , if : 'this.data.user_type=="textarea"' } ]
} ) ;
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-24 13:23:44 +08:00
selectedRowCount : 0 ,
_originalList : [ ] ,
sortConfig : { fieldId : null , direction : null } ,
2026-05-25 08:14:16 +08:00
filterConfig : { } , // fieldId -> { mode, value, value2, selectedValues: [] }
2026-05-24 13:23:44 +08:00
activeFieldId : null ,
2026-05-25 08:14:16 +08:00
activeField : null ,
activeModes : [ ] ,
_columnStats : { } ,
2026-05-24 13:23:44 +08:00
_internalUpdate : false ,
_appliedHash : '' ,
_fieldsDirty : false ,
2026-05-27 23:27:45 +08:00
_masterCellNodes : null ,
isDirty : false ,
isBulkEdit : null
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 ) ;
2026-05-25 08:14:16 +08:00
const scroll = createScrollManager ( container , state , ( ) => selection . applySelectionUI ( ) ) ;
2026-05-22 19:16:45 +08:00
2026-05-25 08:14:16 +08:00
const menuNode = container . querySelector ( '.dt-column-menu' ) ;
if ( menuNode ) menuNode . _thisObj = container ;
2026-05-23 17:37:25 +08:00
2026-05-25 08:14:16 +08:00
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 ] ; }
2026-05-23 17:37:25 +08:00
} ;
2026-05-25 08:14:16 +08:00
let _editorOverlay , currentEditingNode = null ;
2026-05-23 17:37:25 +08:00
2026-05-25 08:14:16 +08:00
container . format = ( val , field ) => {
if ( field . formatter ) return field . formatter ( val , field ) ;
2026-05-28 17:07:51 +08:00
const typeInfo = DataTable . _fieldTypes . get ( field . settings ? . formType || field . type || 'text' ) ;
if ( typeInfo && typeInfo . formatter ) return typeInfo . formatter ( val , field ) ;
2026-05-25 08:14:16 +08:00
return val == null ? '' : ( typeof val === 'object' ? JSON . stringify ( val ) : String ( val ) ) ;
2026-05-22 19:16:45 +08:00
} ;
2026-05-17 17:03:21 +08:00
2026-05-22 19:16:45 +08:00
container . onScroll = ( ) => {
2026-05-25 08:14:16 +08:00
perf . onScroll ( ) ; scroll . refresh ( ) ;
2026-05-25 14:38:16 +08:00
container . hideColumnMenu ( ) ;
2026-05-25 08:14:16 +08:00
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' ; }
2026-05-24 13:23:44 +08:00
} ;
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 } ) ;
2026-05-25 08:14:16 +08:00
if ( state . _appliedHash === currentHash && ! options . force ) return ;
2026-05-24 13:23:44 +08:00
state . _internalUpdate = true ;
2026-05-25 08:14:16 +08:00
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 ) ;
2026-05-24 13:23:44 +08:00
}
} ) ;
} ) ;
2026-05-25 08:14:16 +08:00
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 ;
2026-05-24 13:23:44 +08:00
} ;
container . showColumnMenu = ( field , e ) => {
e . stopPropagation ( ) ;
2026-05-25 08:14:16 +08:00
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 ;
2026-05-24 13:23:44 +08:00
menu . style . display = 'block' ;
2026-05-25 14:38:16 +08:00
const cellNode = btn . closest ( '.dt-cell' ) ;
const rect = cellNode . getBoundingClientRect ( ) , rootRect = container . getBoundingClientRect ( ) ;
2026-05-25 12:28:19 +08:00
const menuWidth = menu . offsetWidth || 260 ;
let leftPos = rect . right - rootRect . left - menuWidth ;
if ( leftPos < 0 ) leftPos = Math . max ( 0 , rect . left - rootRect . left ) ;
menu . style . left = leftPos + 'px' ;
2026-05-24 13:23:44 +08:00
menu . style . top = ( rect . bottom - rootRect . top + 5 ) + 'px' ;
2026-05-25 08:14:16 +08:00
2026-05-24 13:23:44 +08:00
const onGlobalClick = ( ev ) => {
2026-05-25 08:14:16 +08:00
if ( menu . contains ( ev . target ) || btn . contains ( ev . target ) ) return ;
container . hideColumnMenu ( ) ; container . applySortFilter ( ) ;
document . removeEventListener ( 'mousedown' , onGlobalClick ) ;
2026-05-24 13:23:44 +08:00
} ;
document . addEventListener ( 'mousedown' , onGlobalClick ) ;
2026-05-25 08:14:16 +08:00
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 ( ) ;
2026-05-24 13:23:44 +08:00
} ;
container . hideColumnMenu = ( ) => {
const menu = container . querySelector ( '.dt-column-menu' ) ;
if ( menu ) menu . style . display = 'none' ;
} ;
2026-05-25 08:14:16 +08:00
container . setSort = ( dir ) => {
const newDir = state . sortConfig . direction === dir && state . sortConfig . fieldId === state . activeFieldId ? null : dir ;
container . applySortFilter ( { sort : newDir } ) ;
2026-05-24 13:23:44 +08:00
} ;
container . clearColumnSettings = ( ) => {
2026-05-25 08:14:16 +08:00
if ( state . activeFieldId ) {
delete state . filterConfig [ state . activeFieldId ] ;
state . filterConfig = { ... state . filterConfig } ;
container . applySortFilter ( ) ;
}
2026-05-24 13:23:44 +08:00
} ;
container . _initRow = ( rowNode ) => {
2026-05-25 08:14:16 +08:00
const row = rowNode . _ref ? . item ;
if ( row && row . _editingF === undefined ) {
Object . defineProperty ( row , '_editingF' , { set : ( v ) => { if ( v === null ) container . hideEditor ( true ) ; } , configurable : true } ) ;
2026-05-24 13:23:44 +08:00
}
2026-05-25 08:14:16 +08:00
Array . from ( rowNode . children ) . forEach ( cell => {
const fIdx = parseInt ( cell . dataset . fidx ) ;
if ( ! isNaN ( fIdx ) ) cell . _refExt = { f : state . fields [ fIdx ] , fIdx : fIdx } ;
} ) ;
2026-05-23 17:37:25 +08:00
} ;
2026-05-24 13:23:44 +08:00
state . _ _watch ( 'fields' , fields => {
if ( ! fields ) return ;
state . _fieldsDirty = true ;
2026-05-27 23:27:45 +08:00
state . _masterCellNodes = null ; // Force template rebuild
container . style . setProperty ( '--dt-grid-template' , fields . map ( f => ` var(--w- ${ f . id } , ${ ( f . settings ? . width || f . width ) || 150 } px) ` ) . join ( ' ' ) ) ;
container . style . setProperty ( '--dt-row-width' , fields . reduce ( ( sum , f ) => sum + ( ( f . settings ? . width || f . width ) || 150 ) , 0 ) + 'px' ) ;
2026-05-25 14:38:16 +08:00
let leftSum = 0 ;
fields . forEach ( f => {
2026-05-27 23:27:45 +08:00
const pinned = f . settings ? . pinned || f . pinned ;
if ( pinned === 'left' ) {
2026-05-25 14:38:16 +08:00
container . style . setProperty ( ` --l- ${ f . id } ` , leftSum + 'px' ) ;
2026-05-27 23:27:45 +08:00
leftSum += ( ( f . settings ? . width || f . width ) || 150 ) ;
2026-05-25 14:38:16 +08:00
}
} ) ;
let rightSum = 0 ;
[ ... fields ] . reverse ( ) . forEach ( f => {
2026-05-27 23:27:45 +08:00
const pinned = f . settings ? . pinned || f . pinned ;
if ( pinned === 'right' ) {
2026-05-25 14:38:16 +08:00
container . style . setProperty ( ` --r- ${ f . id } ` , rightSum + 'px' ) ;
2026-05-27 23:27:45 +08:00
rightSum += ( ( f . settings ? . width || f . width ) || 150 ) ;
2026-05-25 14:38:16 +08:00
}
} ) ;
2026-05-25 08:14:16 +08:00
} ) ;
2026-05-24 13:23:44 +08:00
2026-05-17 17:03:21 +08:00
state . _ _watch ( 'list' , list => {
2026-05-24 13:23:44 +08:00
if ( state . _fieldsDirty ) {
state . _fieldsDirty = false ;
2026-05-25 08:14:16 +08:00
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 => {
2026-05-27 23:27:45 +08:00
const clone = master . cloneNode ( true ) ;
if ( clone . nodeType === 1 ) {
clone . dataset . fidx = fIdx ;
const pinned = f . settings ? . pinned || f . pinned ;
if ( pinned ) {
clone . classList . add ( 'pinned-' + pinned ) ;
clone . style . position = 'sticky' ;
clone . style . zIndex = '1' ;
clone . style . backgroundColor = 'inherit' ;
if ( pinned === 'left' ) {
clone . style . left = ` var(--l- ${ f . id } ) ` ;
clone . style . borderRight = '1px solid var(--bs-border-color)' ;
clone . style . boxShadow = '2px 0 5px -2px rgba(0,0,0,0.1)' ;
} else {
clone . style . right = ` var(--r- ${ f . id } ) ` ;
clone . style . borderLeft = '1px solid var(--bs-border-color)' ;
clone . style . boxShadow = '-2px 0 5px -2px rgba(0,0,0,0.1)' ;
}
}
}
fieldTemplate . content . appendChild ( clone ) ;
2026-05-25 08:14:16 +08:00
} ) ) ;
2026-05-24 13:23:44 +08:00
}
}
2026-05-25 08:14:16 +08:00
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 ) ;
} ) ;
2026-05-17 17:03:21 +08:00
2026-05-22 20:32:26 +08:00
container . editCell = ( row , field , cellNode ) => {
2026-05-25 08:14:16 +08:00
const overlay = container . querySelector ( '.dt-editor-overlay' ) , rect = cellNode . getBoundingClientRect ( ) , rootRect = container . getBoundingClientRect ( ) ;
2026-05-23 17:37:25 +08:00
currentEditingNode = cellNode ;
2026-05-27 23:27:45 +08:00
const formType = field . settings ? . formType || field . type || 'text' ;
const form = overlay . querySelector ( 'AutoForm' ) ;
if ( form ) {
// row 已经是 Proxy (NewState),直接赋值即可实现双向同步
form . data = row ;
form . state . schema = [ { ... field , type : formType , options : field . settings ? . options || field . options , name : field . id , label : '' } ] ;
RefreshState ( form ) ;
}
2026-05-25 08:14:16 +08:00
Object . assign ( overlay . style , {
display : 'flex' , left : ( rect . left - rootRect . left ) + 'px' , top : ( rect . top - rootRect . top ) + 'px' ,
2026-05-27 23:27:45 +08:00
width : ( formType === 'textarea' || formType === 'TagsInput' ? Math . max ( rect . width , 300 ) : rect . width ) + 'px' ,
height : ( formType === 'textarea' || formType === 'TagsInput' ? 'auto' : rect . height + 'px' )
2026-05-25 08:14:16 +08:00
} ) ;
setTimeout ( ( ) => overlay . querySelector ( 'input, textarea, select, .form-control' ) ? . 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-27 23:27:45 +08:00
const form = _editorOverlay . querySelector ( 'AutoForm' ) ;
if ( save && form && form . data ) {
2026-05-25 08:14:16 +08:00
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 } ) ) ;
2026-05-27 23:27:45 +08:00
RefreshState ( form ) ;
// 手动同步数据回原始行 (解决 Proxy 隔离问题)
const schema = form . state . schema || [ ] ;
schema . forEach ( field => {
const row = currentEditingNode ? . closest ( '.dt-row' ) ? . _ref ? . item ;
if ( row ) row [ field . name ] = form . data [ field . name ] ;
} ) ;
if ( state . isBulkEdit ) {
const { minRow , maxRow , fIdx } = state . isBulkEdit ;
const field = state . fields [ fIdx ] ;
const newValue = form . data [ field . id ] ;
for ( let i = minRow ; i <= maxRow ; i ++ ) {
if ( state . list [ i ] ) {
state . list [ i ] [ field . id ] = newValue ;
}
}
state . list = [ ... state . list ] ;
}
2026-05-25 08:14:16 +08:00
if ( currentEditingNode ) RefreshState ( currentEditingNode ) ;
2026-05-27 23:27:45 +08:00
state . isDirty = true ;
2026-05-25 08:14:16 +08:00
}
2026-05-27 23:27:45 +08:00
_editorOverlay . style . display = 'none' ;
if ( form ) { form . state . schema = [ ] ; form . data = null ; }
currentEditingNode = null ;
state . isBulkEdit = null ;
2026-05-23 17:37:25 +08:00
container . focus ( ) ;
} ;
2026-05-22 19:16:45 +08:00
2026-05-25 08:14:16 +08:00
container . onMainMouseDown = e => {
const cell = e . target . closest ( '.dt-cell' ) , row = cell ? . closest ( '.dt-row' ) ;
if ( ! row || row . classList . contains ( 'dt-header-row' ) ) return ;
2026-05-25 12:25:32 +08:00
const fIdx = cell . dataset . fidx ? parseInt ( cell . dataset . fidx ) : Array . from ( row . querySelectorAll ( '.dt-cell' ) ) . indexOf ( cell ) ;
2026-05-25 08:14:16 +08:00
const rIdx = row . _ref ? . rIdx ? ? Array . from ( container . querySelectorAll ( '.dt-body-row' ) ) . indexOf ( row ) ;
selection . startSelect ( rIdx + state . _listStartIndex , fIdx , e ) ;
} ;
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' ) ) {
2026-05-25 12:25:32 +08:00
const fIdx = cell . dataset . fidx ? parseInt ( cell . dataset . fidx ) : Array . from ( row . querySelectorAll ( '.dt-cell' ) ) . indexOf ( cell ) ;
2026-05-25 08:14:16 +08:00
const rIdx = row . _ref ? . rIdx ? ? Array . from ( container . querySelectorAll ( '.dt-body-row' ) ) . indexOf ( row ) ;
selection . updateSelect ( rIdx + state . _listStartIndex , fIdx ) ;
}
}
} ;
2026-05-22 19:16:45 +08:00
2026-05-25 08:14:16 +08:00
container . onMainDblClick = e => {
const cell = e . target . closest ( '.dt-cell' ) , row = cell ? . closest ( '.dt-row' ) ;
if ( row && ! row . classList . contains ( 'dt-header-row' ) ) {
2026-05-25 12:25:32 +08:00
const item = row . _ref ? . item , fIdx = cell . dataset . fidx ? parseInt ( cell . dataset . fidx ) : Array . from ( row . querySelectorAll ( '.dt-cell' ) ) . indexOf ( cell ) ;
2026-05-27 23:27:45 +08:00
const rIdx = row . _ref ? . rIdx ? ? Array . from ( container . querySelectorAll ( '.dt-body-row' ) ) . indexOf ( row ) ;
const absoluteRow = rIdx + state . _listStartIndex ;
if ( item && state . fields [ fIdx ] ) {
const bounds = selection . getSelectionBounds ( ) ;
if ( bounds && absoluteRow >= bounds . minRow && absoluteRow <= bounds . maxRow && fIdx >= bounds . minCol && fIdx <= bounds . maxCol ) {
const affectedRows = bounds . maxRow - bounds . minRow + 1 ;
if ( affectedRows > 1 ) {
state . isBulkEdit = { ... bounds , fIdx } ;
if ( globalThis . UI ? . toast ) UI . toast ( ` Bulk Edit: Updating ${ affectedRows } rows in column " ${ state . fields [ fIdx ] . name } " ` , { type : 'warning' } ) ;
}
}
container . editCell ( item , state . fields [ fIdx ] , cell ) ;
}
}
} ;
container . addRow = ( ) => {
const newRow = { } ;
state . fields . forEach ( f => newRow [ f . id ] = '' ) ;
state . _originalList . push ( newRow ) ;
state . list = [ ... state . _originalList ] ; // Apply to current view too
state . isDirty = true ;
setTimeout ( ( ) => {
scroll . reset ( state . list ) ;
container . querySelector ( '.dt-main' ) . scrollTop = container . querySelector ( '.dt-main' ) . scrollHeight ;
} , 50 ) ;
} ;
container . deleteSelectedRow = async ( ) => {
const bounds = selection . getSelectionBounds ( ) ;
if ( ! bounds ) return ;
const count = bounds . maxRow - bounds . minRow + 1 ;
if ( await UI . confirm ( ` Are you sure you want to delete ${ count } row(s)? ` ) ) {
const rMin = bounds . minRow , rMax = bounds . maxRow ;
const removedItems = state . list . slice ( rMin , rMax + 1 ) ;
state . list = state . list . filter ( ( _ , i ) => ! ( i >= rMin && i <= rMax ) ) ;
state . _originalList = state . _originalList . filter ( item => ! removedItems . includes ( item ) ) ;
state . isDirty = true ;
selection . clearAllActive ( ) ;
container . dispatchEvent ( new CustomEvent ( 'remove' , { detail : { items : removedItems } } ) ) ;
}
} ;
container . saveChanges = ( ) => {
container . dispatchEvent ( new CustomEvent ( 'save' , { detail : { list : state . _originalList , fields : state . fields } } ) ) ;
state . isDirty = false ;
} ;
2026-05-28 17:07:51 +08:00
const getFieldSchema = ( ) => {
const types = DataTable . getFieldTypes ( ) ;
const baseSchema = [
{ name : 'id' , label : 'Field ID' , type : 'text' , setting : { required : true , placeholder : 'e.g. user_name' } } ,
{ name : 'name' , label : 'Display Name' , type : 'text' , setting : { required : true , placeholder : 'e.g. 用户名' } } ,
{ name : 'user_type' , label : 'Field Type' , type : 'select' , options : types . map ( t => ( { label : t . label , value : t . value } ) ) }
] ;
const dynamicSchema = types . reduce ( ( acc , t ) => acc . concat ( t . schema || [ ] ) , [ ] ) ;
return baseSchema . concat ( dynamicSchema , [
{ name : 'isIndex' , label : 'Index' , type : 'switch' } ,
{ name : 'memo' , label : 'Memo' , type : 'text' }
] ) ;
} ;
const parseOptionsStr = ( str ) => {
if ( ! str ) return undefined ;
return str . split ( '\n' ) . map ( s => s . trim ( ) ) . filter ( Boolean ) . map ( line => {
const idx = line . indexOf ( ':' ) ;
if ( idx > - 1 ) return { label : line . slice ( 0 , idx ) . trim ( ) , value : line . slice ( idx + 1 ) . trim ( ) } ;
return line ;
} ) ;
} ;
const formatOptionsStr = ( opts ) => {
if ( ! opts ) return '' ;
return opts . map ( o => typeof o === 'object' ? ` ${ o . label } : ${ o . value } ` : o ) . join ( '\n' ) ;
} ;
2026-05-27 23:27:45 +08:00
container . addField = async ( ) => {
container . hideColumnMenu ( ) ;
const data = NewState ( { id : 'c' + Date . now ( ) . toString ( ) . slice ( - 4 ) , name : 'New Field' , user _type : 'text' , decimals : 0 , isIndex : false , memo : '' , options _str : '' } ) ;
const d = document . body . appendChild ( document . createElement ( 'Dialog' ) ) ;
await new Promise ( r => setTimeout ( r , 0 ) ) ;
Object . assign ( d . state , { title : 'Add Field' , buttons : [ 'Cancel' , 'Save' ] } ) ;
RefreshState ( d ) ;
const body = d . querySelector ( '.modal-body' ) ;
const form = body . appendChild ( document . createElement ( 'AutoForm' ) ) ;
form . setAttribute ( 'nobutton' , '' ) ;
RefreshState ( form ) ;
form . data = data ;
form . state . schema = getFieldSchema ( ) ;
form . addEventListener ( 'change' , ( e ) => e . stopPropagation ( ) ) ;
d . show ( ) ;
const result = await new Promise ( resolve => d . addEventListener ( 'change' , e => resolve ( d . result ) ) ) ;
if ( result === 2 ) {
2026-05-28 17:07:51 +08:00
const typeInfo = DataTable . _fieldTypes . get ( data . user _type ) ;
let dbType = typeInfo ? . typeForDB || 'v1024' ;
2026-05-27 23:27:45 +08:00
if ( data . user _type === 'number' ) dbType = data . decimals > 0 ? 'ff' : 'bi' ;
const field = {
id : data . id , name : data . name , memo : data . memo , isIndex : ! ! data . isIndex ,
type : dbType ,
settings : {
formType : data . user _type ,
decimals : data . decimals ,
2026-05-28 17:07:51 +08:00
prefix : data . prefix , suffix : data . suffix , thousandSep : data . thousandSep ,
labelOn : data . labelOn , labelOff : data . labelOff , format : data . format ,
placeholder : data . placeholder ,
options : parseOptionsStr ( data . options _str )
2026-05-27 23:27:45 +08:00
}
} ;
state . fields = [ ... state . fields , field ] ;
state . isDirty = true ;
container . dispatchEvent ( new CustomEvent ( 'savefields' , { detail : state . fields } ) ) ;
state . list = [ ... state . list ] ;
}
d . remove ( ) ;
} ;
container . editField = async ( ) => {
if ( ! state . activeField ) return ;
container . hideColumnMenu ( ) ;
const f = state . activeField ;
2026-05-28 17:07:51 +08:00
const s = f . settings || { } ;
2026-05-27 23:27:45 +08:00
const data = NewState ( {
id : f . id , name : f . name , memo : f . memo || '' , isIndex : ! ! f . isIndex ,
2026-05-28 17:07:51 +08:00
user _type : s . formType || 'text' ,
decimals : s . decimals || 0 ,
prefix : s . prefix || '' , suffix : s . suffix || '' , thousandSep : ! ! s . thousandSep ,
labelOn : s . labelOn || '' , labelOff : s . labelOff || '' , format : s . format || '' ,
placeholder : s . placeholder || '' ,
options _str : formatOptionsStr ( s . options )
2026-05-27 23:27:45 +08:00
} ) ;
const d = document . body . appendChild ( document . createElement ( 'Dialog' ) ) ;
await new Promise ( r => setTimeout ( r , 0 ) ) ;
Object . assign ( d . state , { title : 'Edit Field' , buttons : [ 'Cancel' , 'Save' ] } ) ;
RefreshState ( d ) ;
const body = d . querySelector ( '.modal-body' ) ;
const form = body . appendChild ( document . createElement ( 'AutoForm' ) ) ;
form . setAttribute ( 'nobutton' , '' ) ;
RefreshState ( form ) ;
form . data = data ;
form . state . schema = getFieldSchema ( ) ;
form . addEventListener ( 'change' , ( e ) => e . stopPropagation ( ) ) ;
d . show ( ) ;
const result = await new Promise ( resolve => d . addEventListener ( 'change' , e => resolve ( d . result ) ) ) ;
if ( result === 2 ) {
const idx = state . fields . findIndex ( item => item . id === f . id ) ;
if ( idx !== - 1 ) {
2026-05-28 17:07:51 +08:00
const typeInfo = DataTable . _fieldTypes . get ( data . user _type ) ;
let dbType = typeInfo ? . typeForDB || 'v1024' ;
2026-05-27 23:27:45 +08:00
if ( data . user _type === 'number' ) dbType = data . decimals > 0 ? 'ff' : 'bi' ;
const updatedField = {
... f , id : data . id , name : data . name , memo : data . memo , isIndex : ! ! data . isIndex ,
type : dbType ,
settings : {
... f . settings ,
formType : data . user _type ,
decimals : data . decimals ,
2026-05-28 17:07:51 +08:00
prefix : data . prefix , suffix : data . suffix , thousandSep : data . thousandSep ,
labelOn : data . labelOn , labelOff : data . labelOff , format : data . format ,
placeholder : data . placeholder ,
options : parseOptionsStr ( data . options _str )
2026-05-27 23:27:45 +08:00
}
} ;
state . fields [ idx ] = updatedField ;
state . fields = [ ... state . fields ] ;
state . isDirty = true ;
container . dispatchEvent ( new CustomEvent ( 'savefields' , { detail : state . fields } ) ) ;
state . list = [ ... state . list ] ;
}
}
d . remove ( ) ;
} ;
container . deleteField = async ( ) => {
if ( ! state . activeField ) return ;
container . hideColumnMenu ( ) ;
if ( await UI . confirm ( ` Are you sure you want to delete field " ${ state . activeField . name } "? ` ) ) {
const idx = state . fields . findIndex ( f => f . id === state . activeField . id ) ;
if ( idx !== - 1 ) {
state . fields . splice ( idx , 1 ) ;
state . fields = [ ... state . fields ] ;
state . isDirty = true ;
container . dispatchEvent ( new CustomEvent ( 'savefields' , { detail : state . fields } ) ) ;
state . list = [ ... state . list ] ; // Trigger body refresh
}
2026-05-25 08:14:16 +08:00
}
} ;
window . addEventListener ( 'mouseup' , selection . endSelect ) ;
document . addEventListener ( 'mousedown' , e => {
2026-05-23 17:37:25 +08:00
const overlay = container . querySelector ( '.dt-editor-overlay' ) ;
2026-05-27 23:27:45 +08:00
const menu = container . querySelector ( '.dt-column-menu' ) ;
2026-05-25 08:14:16 +08:00
if ( overlay ? . style . display !== 'none' && ! overlay . contains ( e . target ) ) container . hideEditor ( true ) ;
2026-05-27 23:27:45 +08:00
if ( ! container . contains ( e . target ) && ! overlay ? . contains ( e . target ) && ! menu ? . contains ( e . target ) ) selection . clearAllActive ( ) ;
2026-05-25 08:14:16 +08:00
} ) ;
2026-05-23 17:37:25 +08:00
2026-05-25 08:14:16 +08:00
// Exposure for templates
state . _MODE _ICONS = MODE _ICONS ;
2026-05-17 17:03:21 +08:00
2026-05-27 23:27:45 +08:00
// NOTE: For $class and $style directives, ALWAYS use the template literal syntax:
// $class="base-class \${condition ? 'active' : ''}"
// DO NOT use string concatenation like $class="'base-class ' + (condition ? 'active' : '')".
// Since the HTML is wrapped in backticks (``), remember to escape the dollar sign: \${ }
2026-05-17 17:03:21 +08:00
} , Util . makeDom ( /*html*/ `
2026-05-25 08:14:16 +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" tabindex = "0" >
2026-05-23 17:37:25 +08:00
< div class = "dt-main flex-grow-1 overflow-auto" $onscroll = "this.onScroll()"
2026-05-25 08:14:16 +08:00
$onmousedown = "this.onMainMouseDown(event)" $onmouseover = "this.onMainMouseOver(event)" $ondblclick = "this.onMainDblClick(event)"
2026-05-23 17:37:25 +08:00
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" >
2026-05-25 08:14:16 +08:00
< div class = "dt-header-row fw-bold text-muted small" >
2026-05-24 13:23:44 +08:00
< template $each = "this.state?.fields || []" >
2026-05-27 23:27:45 +08:00
< div $data - id = "item.id" $class = "dt-cell dt-col border-end d-flex align-items-center header-cell \${(item.settings?.pinned || item.pinned) ? 'pinned-' + (item.settings?.pinned || item.pinned) : ''}" $style = "((item.settings?.pinned || item.pinned) ? 'position: sticky; z-index: 11; background-color: inherit; ' : 'position:relative; ') + 'padding: 0; ' + ((item.settings?.pinned || item.pinned) === 'left' ? 'left: var(--l-' + item.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((item.settings?.pinned || item.pinned) === 'right' ? 'right: var(--r-' + item.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))" >
2026-05-25 08:14:16 +08:00
< div class = "d-flex align-items-center overflow-hidden flex-grow-1 h-100 px-2 cursor-pointer" $onclick = "this.showColumnMenu(item, event)" >
< i $if = "this.state?.filterConfig?.[item.id] && (this.state.filterConfig[item.id].value || this.state.filterConfig[item.id].selectedValues?.length)" class = "bi bi-filter me-1 text-primary" > < / i >
2026-05-25 14:45:12 +08:00
< i $if = "this.state?.sortConfig?.fieldId === item.id && this.state.sortConfig.direction" $class = "bi bi-sort-\${this.state.sortConfig.direction === 'asc' ? 'down' : 'up-alt'} me-1 text-primary" > < / i >
2026-05-25 08:14:16 +08:00
< span $text = "item.name" class = "text-truncate flex-grow-1" > < / s p a n >
2026-05-24 13:23:44 +08:00
< / d i v >
2026-05-25 08:14:16 +08:00
< button class = "btn btn-xs btn-link text-muted p-0 border-0 me-1 header-menu-btn" $onclick = "this.showColumnMenu(item, event)" > < i class = "bi bi-chevron-down" > < / i > < / b u t t o n >
< Resizer $ . target = "thisNode.parentElement" style = "position:absolute; right:0; top:0; bottom:0; width:4px; z-index:10" min = "50" max = "1000" $onresizing = "this.onColumnResizing(item, event)" $onresize = "this.onColumnResize(item, event)" > < / R e s i z e r >
2026-05-24 13:23:44 +08:00
< / d i v >
< / t e m p l a t e >
2026-05-22 12:32:50 +08:00
< / d i v >
2026-05-17 17:03:21 +08:00
< / d i v >
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" > < / d i v >
2026-05-24 13:23:44 +08:00
< template $each = "this.state?._renderedList || []" key = "id" index = "rIdx" >
< div class = "dt-row dt-body-row border-bottom bg-white" $ . = "this._initRow(thisNode)" >
2026-05-27 23:27:45 +08:00
< template as = "f" > < div $class = "dt-cell border-end px-2 d-flex align-items-center \${(f.settings?.pinned || f.pinned) ? 'pinned-' + (f.settings?.pinned || f.pinned) : ''}" $style = "((f.settings?.pinned || f.pinned) ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + ((f.settings?.pinned || f.pinned) === 'left' ? 'left: var(--l-' + f.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((f.settings?.pinned || f.pinned) === 'right' ? 'right: var(--r-' + f.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))" > < span $text = "this.format(item[f.id], f)" class = "text-truncate" > < / s p a n > < / d i v > < / t e m p l a t e >
2026-05-22 19:16:45 +08:00
< / d i v >
2026-05-24 13:23:44 +08:00
< / t e m p l a t e >
2026-05-23 17:37:25 +08:00
< div class = "dt-spacer-post flex-shrink-0" style = "display:none" > < / d i v >
2026-05-22 19:16:45 +08:00
< / d i v >
2026-05-17 17:03:21 +08:00
< / d i v >
2026-05-23 17:37:25 +08:00
2026-05-27 23:27:45 +08:00
< div class = "dt-column-menu bg-body shadow-lg rounded p-2" style = "display:none; position:absolute; z-index:2000; min-width:240px; max-width:300px; border: 1px solid var(--bs-primary)" >
2026-05-25 08:14:16 +08:00
< template $if = "this.state?.activeFieldId" >
2026-05-27 23:27:45 +08:00
< div class = "d-flex gap-1 mb-2" >
< button $class = "btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'asc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick = "this.setSort('asc')" > < i class = "bi bi-sort-alpha-down me-1" > < / i > A S C < / b u t t o n >
< button $class = "btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'desc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick = "this.setSort('desc')" > < i class = "bi bi-sort-alpha-up-alt me-1" > < / i > D E S C < / b u t t o n >
2026-05-24 13:23:44 +08:00
< / d i v >
2026-05-25 08:14:16 +08:00
< div $if = "this.state?.activeModes?.length" class = "dt-filter-tabs d-flex overflow-auto border-bottom bg-light-subtle rounded-top py-1" style = "white-space:nowrap; scrollbar-width: none;" >
< template $each = "this.state?.activeModes || []" as = "m" >
< div $class = "px-2 py-1 cursor-pointer fs-5 \${this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === m ? 'text-primary border-bottom border-primary border-2' : 'text-muted'}" $title = "m.toUpperCase()" $onclick = "this.state.filterConfig[this.state.activeFieldId].mode = m; this.state.filterConfig = {...this.state.filterConfig}" >
< i $class = "bi \${this.state?._MODE_ICONS?.[m] || 'bi-filter'}" > < / i >
< / d i v >
< / t e m p l a t e >
< / d i v >
2026-05-23 17:37:25 +08:00
2026-05-25 14:45:12 +08:00
< template $if = "this.state?.activeModes?.length" >
< div class = "py-2 border-bottom" style = "min-height: 48px" >
2026-05-25 08:14:16 +08:00
< input type = "text" class = "form-control form-control-sm mb-1" $placeholder = "(this.state?.filterConfig?.[this.state?.activeFieldId]?.mode || 'Search').toUpperCase() + '...'" $bind = "this.state?.filterConfig?.[this.state?.activeFieldId].value" $onkeydown = "if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}" >
< input $if = "this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === 'between'" type = "text" class = "form-control form-control-sm" placeholder = "And..." $bind = "this.state?.filterConfig?.[this.state?.activeFieldId].value2" $onkeydown = "if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}" >
< / d i v >
2026-05-25 14:45:12 +08:00
< / t e m p l a t e >
2026-05-25 08:14:16 +08:00
2026-05-27 23:27:45 +08:00
< div class = "mt-2" style = "max-height: 180px; overflow-y: auto;" >
< div class = "text-muted fw-bold mb-1" style = "font-size: 9px; letter-spacing: 0.5px" > TOP FREQUENT VALUES < / d i v >
2026-05-25 08:14:16 +08:00
< template $each = "this.state?._columnStats?.[this.state?.activeFieldId] || []" >
< label class = "d-flex align-items-center mb-1 small cursor-pointer p-1 rounded-1 menu-item-row" onmouseover = "this.style.background='var(--bs-light)'" onmouseout = "this.style.background='transparent'" >
< input type = "checkbox" class = "form-check-input me-2" $checked = "this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.includes(String(item.val))" $onclick = "this.toggleSelectedValue(String(item.val))" >
< span class = "text-truncate flex-grow-1" > < span $text = "item.val || '(Empty)'" > < / s p a n > < s p a n c l a s s = " t e x t - m u t e d m s - 1 " s t y l e = " f o n t - s i z e : 0 . 7 r e m " $ t e x t = " ' ( ' + i t e m . c o u n t + ' ) ' " > < / s p a n > < / s p a n >
< button class = "btn btn-xs btn-link p-0 text-primary only-btn" style = "font-size: 10px; text-decoration: none" $onclick = "this.filterOnlyThis(item.val); event.preventDefault(); event.stopPropagation();" > Only < / b u t t o n >
< / l a b e l >
< / t e m p l a t e >
< / d i v >
2026-05-27 23:27:45 +08:00
< div $if = "this.state?.filterConfig?.[this.state?.activeFieldId]?.value || this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.length" class = "mt-2 pt-1 border-top text-center" >
2026-05-25 08:14:16 +08:00
< span class = "cursor-pointer text-primary small fw-bold" $onclick = "this.clearColumnSettings()" > < i class = "bi bi-x-circle me-1" > < / i > C l e a r F i l t e r < / s p a n >
< / d i v >
2026-05-27 23:27:45 +08:00
< div class = "mt-3 pt-2 border-top d-flex flex-column gap-1" >
< button class = "btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick = "this.editField()" > < i class = "bi bi-pencil me-2" > < / i > E d i t F i e l d < / b u t t o n >
< button class = "btn btn-xs btn-outline-secondary border d-flex align-items-center px-2 py-1" $onclick = "this.addField()" > < i class = "bi bi-plus-lg me-2" > < / i > A d d F i e l d < / b u t t o n >
< button class = "btn btn-xs btn-outline-danger border d-flex align-items-center px-2 py-1" $onclick = "this.deleteField()" > < i class = "bi bi-trash me-2" > < / i > D e l e t e F i e l d < / b u t t o n >
< / d i v >
2026-05-25 08:14:16 +08:00
< / t e m p l a t e >
2026-05-17 17:03:21 +08:00
< / d i v >
2026-05-25 08:14:16 +08:00
2026-05-27 23:27:45 +08:00
< div class = "dt-editor-overlay dt-editor-container" style = "display: none; 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);" > < AutoForm inline class = "h-100 w-100" $onsubmit = "event.preventDefault(); thisNode.closest('DataTable').hideEditor(true)" / > < / d i v >
< div class = "dt-footer border-top bg-light d-flex align-items-center px-3 py-1 shadow-sm" style = "height:40px; z-index: 10" >
< div class = "d-flex align-items-center gap-3 flex-grow-1" >
< div class = "btn-group shadow-sm" >
< button class = "btn btn-xs btn-white border d-flex align-items-center px-2" style = "background:white" $onclick = "this.addRow()" title = "Add Row" > < i class = "bi bi-plus-lg text-primary me-1" > < / i > A d d < / b u t t o n >
< button class = "btn btn-xs btn-white border d-flex align-items-center px-2" style = "background:white" $onclick = "this.deleteSelectedRow()" $disabled = "!this.state?.selectedRowCount" title = "Delete Selected Rows" > < i class = "bi bi-trash text-danger me-1" > < / i > D e l e t e < / b u t t o n >
< / d i v >
< div class = "vr h-50 my-auto text-muted opacity-25" > < / d i v >
< div class = "d-flex align-items-center gap-2 text-muted" style = "font-size: 0.75rem" >
< i class = "bi bi-check-all fs-6" > < / i >
< span $text = "(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'" > < / s p a n >
< / d i v >
< / d i v >
< div class = "d-flex align-items-center gap-2" >
< button $if = "this.state?.isDirty" class = "btn btn-xs btn-primary px-3 shadow-sm d-flex align-items-center fw-bold" $onclick = "this.saveChanges()" > < i class = "bi bi-cloud-upload me-1" > < / i > S a v e C h a n g e s < / b u t t o n >
< button $if = "!this.state?.isDirty" class = "btn btn-xs btn-light border px-3 text-muted disabled d-flex align-items-center" disabled > < i class = "bi bi-cloud-check me-1" > < / i > U p t o d a t e < / b u t t o n >
< / d i v >
< / d i v >
2026-05-17 17:03:21 +08:00
< / d i v >
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.875 rem ; }
2026-05-25 08:14:16 +08:00
. dt - row , . dt - header - row { display : grid ; grid - template - columns : var ( -- dt - grid - template ) ; width : var ( -- dt - row - width , max - content ) ; min - width : 100 % ; height : 40 px ; contain : paint layout ; }
. dt - header - row { background - color : var ( -- bs - tertiary - bg ) ; border - bottom : 1 px solid var ( -- bs - border - color ) ; }
2026-05-24 13:23:44 +08:00
. dt - cell { background : inherit ; white - space : nowrap ; flex - shrink : 0 ; contain : content ; }
2026-05-25 12:25:32 +08:00
. dt - cell - selected { background - color : rgba ( var ( -- bs - primary - rgb ) , 0.15 ) ! important ; outline : 1 px solid var ( -- bs - primary ) ; outline - offset : - 1 px ; }
2026-05-24 13:23:44 +08:00
. dt - body - row : hover { background - color : var ( -- bs - secondary - bg ) ! important ; }
. header - cell . header - menu - btn { opacity : 0 ; transition : opacity 0.2 s ; }
. header - cell : hover . header - menu - btn { opacity : 1 ; }
2026-05-25 14:45:12 +08:00
. dt - column - menu { background - color : var ( -- bs - body - bg ) ; border : 1 px solid var ( -- bs - primary ) ; box - shadow : 0 10 px 40 px rgba ( 0 , 0 , 0 , 0.2 ) ! important ; z - index : 2100 ! important ; }
2026-05-24 13:23:44 +08:00
. btn - xs { padding : 1 px 5 px ; line - height : 1.5 ; }
2026-05-25 08:14:16 +08:00
. cursor - pointer { cursor : pointer ; }
. dt - filter - tabs i { font - size : 1.1 rem ; }
. dt - filter - tabs div : hover i { color : var ( -- bs - primary ) ; }
. menu - item - row . only - btn { opacity : 0 ; }
. menu - item - row : hover . only - btn { opacity : 1 ; }
2026-05-22 12:32:50 +08:00
< / s t y l e >
2026-05-17 17:03:21 +08:00
` ))