262 lines
12 KiB
JavaScript
262 lines
12 KiB
JavaScript
/**
|
|
* List Component Module
|
|
*/
|
|
globalThis.Component.register('List', container => {
|
|
container.mode = container.getAttribute('mode') || 'normal'
|
|
container.fast = container.hasAttribute('fast')
|
|
container.collapsible = container.hasAttribute('collapsible')
|
|
|
|
const padTopEl = container.fast ? container.querySelector('.vs-pad-top') : null
|
|
const padBottomEl = container.fast ? container.querySelector('.vs-pad-bottom') : null
|
|
|
|
const defaultSets = {
|
|
idfield: 'id', labelfield: 'label', summaryfield: 'summary',
|
|
groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group',
|
|
parentfield: 'parent', groupicon: 'folder', itemicon: 'file'
|
|
}
|
|
// Field names are ordinary declarative attributes, so static markup needs no setup script.
|
|
Object.keys(defaultSets).forEach(name => {
|
|
if (container.hasAttribute(name)) container[name] = container.getAttribute(name)
|
|
})
|
|
container.collapsed = globalThis.NewState({})
|
|
container.state.renderedList = []
|
|
|
|
container.addEventListener('bind', e => {
|
|
container.state.selectedItem = e.detail
|
|
})
|
|
|
|
const matchString = (val, query) => {
|
|
if (query === undefined || query === null || query === '') return true
|
|
return String(val || '').toLowerCase().includes(String(query).toLowerCase())
|
|
}
|
|
|
|
const matchesItem = (item, filter, filterFunc) => {
|
|
if (filterFunc && !filterFunc(item)) return false
|
|
if (filter === 0) filter = ''
|
|
|
|
if (filter !== undefined && filter !== null && filter !== '') {
|
|
if (typeof filter === 'object') {
|
|
for (const [key, qVal] of Object.entries(filter)) {
|
|
if (qVal !== undefined && qVal !== null && qVal !== '') {
|
|
if (!matchString(item[key], qVal)) return false
|
|
}
|
|
}
|
|
} else {
|
|
const q = String(filter).trim()
|
|
if (q) {
|
|
const labelMatches = matchString(item[container.labelfield], q)
|
|
const summaryMatches = matchString(item[container.summaryfield], q)
|
|
if (!labelMatches && !summaryMatches) return false
|
|
}
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
const updateFlatList = () => {
|
|
globalThis.Util.updateDefaults(container, defaultSets)
|
|
let list = container.state.list || []
|
|
let filter = container.state.filter
|
|
let order = container.state.order
|
|
const filterFunc = container._filterFunc
|
|
const orderFunc = container._orderFunc
|
|
|
|
if (filter === 0) filter = ''
|
|
if (order === 0) order = ''
|
|
|
|
if (orderFunc) {
|
|
list = [...list].sort(orderFunc)
|
|
} else if (order) {
|
|
if (typeof order === 'string') {
|
|
const parts = order.trim().split(/\s+/)
|
|
const field = parts[0]
|
|
const isDesc = parts[1]?.toLowerCase() === 'desc' || field.startsWith('-')
|
|
const cleanField = field.startsWith('-') ? field.slice(1) : field
|
|
list = [...list].sort((a, b) => {
|
|
const valA = a[cleanField]
|
|
const valB = b[cleanField]
|
|
if (valA === undefined || valA === null) return isDesc ? 1 : -1
|
|
if (valB === undefined || valB === null) return isDesc ? -1 : 1
|
|
if (typeof valA === 'string' && typeof valB === 'string') {
|
|
return isDesc ? valB.localeCompare(valA) : valA.localeCompare(valB)
|
|
}
|
|
return isDesc ? (valB > valA ? 1 : -1) : (valA > valB ? 1 : -1)
|
|
})
|
|
}
|
|
}
|
|
|
|
const flatList = []
|
|
if (container.mode === 'group') {
|
|
const filteredList = list.filter(item => matchesItem(item, filter, filterFunc))
|
|
const itemMap = {}
|
|
filteredList.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
|
|
(container.state.groups || []).forEach(group => {
|
|
const items = itemMap[group[container.groupidfield]]
|
|
let groupMatches = false
|
|
if (filter !== undefined && filter !== null && filter !== '') {
|
|
if (typeof filter === 'object') {
|
|
groupMatches = true
|
|
for (const [key, qVal] of Object.entries(filter)) {
|
|
if (qVal !== undefined && qVal !== null && qVal !== '') {
|
|
if (!matchString(group[key], qVal)) {
|
|
groupMatches = false
|
|
break
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
const q = String(filter).trim()
|
|
if (q) {
|
|
groupMatches = matchString(group[container.grouplabelfield], q) ||
|
|
matchString(group[container.groupsummaryfield], q)
|
|
}
|
|
}
|
|
}
|
|
|
|
if ((items && items.length > 0) || groupMatches || (!filter && !filterFunc)) {
|
|
flatList.push({ type: 'group', ...group })
|
|
if (items) items.forEach(item => flatList.push({ type: 'item', ...item }))
|
|
}
|
|
})
|
|
} else if (container.mode === 'tree') {
|
|
const itemMap = {}
|
|
list.forEach(item => { itemMap[item[container.idfield]] = item })
|
|
|
|
const matches = new Set()
|
|
list.forEach(item => {
|
|
if (matchesItem(item, filter, filterFunc)) {
|
|
matches.add(item[container.idfield])
|
|
}
|
|
})
|
|
|
|
const keep = new Set(matches)
|
|
if (filter || filterFunc) {
|
|
matches.forEach(id => {
|
|
let curr = itemMap[id]
|
|
while (curr) {
|
|
const parentId = curr[container.parentfield]
|
|
if (!parentId) break
|
|
if (keep.has(parentId)) break
|
|
keep.add(parentId)
|
|
curr = itemMap[parentId]
|
|
}
|
|
})
|
|
}
|
|
|
|
const childrenMap = {}
|
|
list.forEach(item => {
|
|
if ((!filter && !filterFunc) || keep.has(item[container.idfield])) {
|
|
const parentId = item[container.parentfield] || ''
|
|
; (childrenMap[parentId] ??= []).push(item)
|
|
}
|
|
})
|
|
|
|
const traverse = (items, level, parents) => items.forEach(item => {
|
|
const id = item[container.idfield]
|
|
const hasChildren = !!childrenMap[id]?.length
|
|
const isCollapsed = container.collapsed[id]
|
|
flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
|
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id])
|
|
})
|
|
traverse(childrenMap[''] || [], 0, [])
|
|
} else {
|
|
const filteredList = list.filter(item => matchesItem(item, filter, filterFunc))
|
|
filteredList.forEach(item => flatList.push({ type: 'item', ...item }))
|
|
}
|
|
container.state.flatList = flatList
|
|
}
|
|
|
|
container.state.__watch('list', updateFlatList)
|
|
container.state.__watch('filter', updateFlatList)
|
|
container.state.__watch('order', updateFlatList)
|
|
const vs = container.fast ? globalThis.VirtualScroll() : null
|
|
|
|
let refreshing = false
|
|
container.refresh = () => {
|
|
if (!container.fast || refreshing) return
|
|
refreshing = true
|
|
try {
|
|
const res = vs.calc(container, container.state.flatList)
|
|
if (res) {
|
|
if (padTopEl) padTopEl.style.height = `${res.prevHeight}px`
|
|
if (padBottomEl) padBottomEl.style.height = `${res.postHeight}px`
|
|
container.state.listStartIndex = res.listStartIndex
|
|
container.state.renderedList = res.renderedList
|
|
}
|
|
} finally {
|
|
setTimeout(() => { refreshing = false }, 0)
|
|
}
|
|
}
|
|
|
|
container.onItemUpdate = (index, node) => { if (container.fast) vs.update(index + (container.state.listStartIndex || 0), node) }
|
|
|
|
container.state.__watch('flatList', flatList => {
|
|
if (container.fast) {
|
|
if (padTopEl) padTopEl.style.height = '0px'
|
|
if (padBottomEl) padBottomEl.style.height = '0px'
|
|
container.state.listStartIndex = 0
|
|
container.state.renderedList = vs.reset(flatList, container) || []
|
|
setTimeout(() => { if (container.state.flatList === flatList) vs.init(flatList, container.refresh) })
|
|
} else container.state.renderedList = flatList
|
|
})
|
|
|
|
container.selectItem = (item, index) => {
|
|
if (container.hasAttribute('auto-select')) {
|
|
container.state.selectedItem = container.state.selectedItem === item[container.idfield] ? null : item[container.idfield]
|
|
}
|
|
container.dispatchEvent(new CustomEvent('change', { detail: container.state.selectedItem, bubbles: false }))
|
|
container.dispatchEvent(new CustomEvent('itemclick', { bubbles: false, detail: { item, index: index + (container.fast ? (container.state.listStartIndex || 0) : 0) } }))
|
|
}
|
|
container.selectGroup = (item, index) => {
|
|
if (container.hasAttribute('auto-select-group')) container.state.selectedGroup = container.state.selectedGroup === item[container.groupidfield] ? null : item[container.groupidfield]
|
|
container.dispatchEvent(new CustomEvent('groupclick', { bubbles: false, detail: { item, index } }))
|
|
}
|
|
container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } }
|
|
|
|
Object.defineProperty(container, 'filterFunc', {
|
|
get: () => container._filterFunc,
|
|
set: v => { container._filterFunc = v; updateFlatList(); }
|
|
})
|
|
Object.defineProperty(container, 'orderFunc', {
|
|
get: () => container._orderFunc,
|
|
set: v => { container._orderFunc = v; updateFlatList(); }
|
|
})
|
|
|
|
updateFlatList()
|
|
}, globalThis.Util.makeDom(/*html*/`
|
|
<div class="list-group overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
|
|
<div class="vs-pad-top flex-shrink-0" style="height:0px;"></div>
|
|
<template slot-id="item" $each="this.state.renderedList">
|
|
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item d-inline-flex align-items-center pe-2 \${item.type==='group'?'bg-body-tertiary fw-bold ps-2':'list-group-item-action ' + (this.mode==='group'?'ps-4':'ps-2')} \${item.type==='group'?(this.state?.selectedGroup===item[this.groupidfield]?'active':''):(this.state?.selectedItem===item[this.idfield]?'active':'')}" $onclick="item.type==='group'?this.selectGroup(item,index):this.selectItem(item,index)">
|
|
<template $if="item.type === 'group'">
|
|
<template $if="this.groupicon">
|
|
<span $class="bi bi-\${this.groupicon} text-body"></span>
|
|
</template>
|
|
<div class="flex-shrink-0 px-1" $text="\${item[this.grouplabelfield]}"></div>
|
|
<div class="text-muted small flex-fill text-end" $text="\${item[this.groupsummaryfield] || ''}"></div>
|
|
<div slot-id="group-actions"></div>
|
|
</template>
|
|
<template $if="item.type === 'item'">
|
|
<template $if="this.mode === 'tree'">
|
|
<div $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
|
|
<template $if="this.collapsible && item._hasChildren">
|
|
<i $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
<template $if="this.mode === 'tree'">
|
|
<span $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
|
|
</template>
|
|
<template $if="this.mode !== 'tree' && this.itemicon">
|
|
<span $class="bi bi-\${this.itemicon} text-body"></span>
|
|
</template>
|
|
<div class="flex-shrink-0 px-1" $text="\${item[this.labelfield] || ''}"></div>
|
|
<div class="text-muted fs-7 small ms-2 flex-fill text-truncate text-end" $text="\${item[this.summaryfield] || ''}"></div>
|
|
<div slot-id="item-actions"></div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
<div class="vs-pad-bottom flex-shrink-0" style="height:0px;"></div>
|
|
</div>
|
|
`))
|