feat/fix: 深度修复虚拟滚动布局偏移与触底 Bug,并优化测试环境 UI (by AI)

This commit is contained in:
AI Engineer 2026-05-20 22:52:44 +08:00
parent 0dd085f2b0
commit f5f57638df
8 changed files with 840 additions and 40 deletions

View File

@ -1,5 +1,12 @@
# CHANGELOG
## v1.0.4 (2026-05-20)
### 修复
- **FastList**: 修复了在绝对底部滚动时,由于 Flex 布局的 `rowGap` 被重复计算到虚拟占位高度中,导致实际内容出现偏移(底部出现无法消除的空白区域)的问题。
- **FastList**: 修复了极端情况下,滚动高度超过预估总高度时导致列表无法渲染(空白)的边界 Bug。
- **性能优化**: 在不破坏原有精确滚动计算逻辑的前提下,优化了 `calc` 中的高度累加性能,并修正了测试用例因未触发 `scroll` 事件而导致更新失败的问题。
## v1.0.3 (2026-05-18)
### 修复

35
dist/base.js vendored
View File

@ -384,11 +384,7 @@ const VirtualScroll = () => {
if (listInited) return;
const size = list.length, defaultHeight = avg.get() || 32;
for (let i = 0; i < size; i++) if (!itemHeights.has(i)) itemHeights.set(i, defaultHeight);
for (let i = 0; i < size; i += groupItemCount) {
let sum = 0;
for (let j = i; j < Math.min(i + groupItemCount, size); j++) sum += itemHeights.get(j);
groupHeights.set(i, sum);
}
for (let i = 0; i < size; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, size - i) * defaultHeight);
listInited = true;
refreshCallback();
},
@ -398,7 +394,7 @@ const VirtualScroll = () => {
itemMarginTop = parseFloat(style.marginTop) || 0;
itemMarginBottom = parseFloat(style.marginBottom) || 0;
}
if (absoluteIndex === 0) topMargin = itemMarginTop;
if (absoluteIndex === 0 && !topMargin) topMargin = itemMarginTop;
const newHeight = node.offsetHeight + itemMarginTop + itemMarginBottom + rowGap;
const oldHeight = itemHeights.get(absoluteIndex);
if (newHeight !== oldHeight) {
@ -410,23 +406,28 @@ const VirtualScroll = () => {
},
calc: (container, list) => {
if (!listInited || !list) return null;
const size = list.length, visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32));
const size = list.length;
let visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (avg.get() || 32)));
let prev = padTop + topMargin + rowGap, post = 0, status = 0, listStartIndex = 0, listEndIndex = 0;
let renderedList = [];
const scrollTop = container.scrollTop;
for (let i = 0; i < size; i++) {
if (status === 0) {
const gh = groupHeights.get(i);
if (gh && prev + gh < container.scrollTop) {
if (gh && prev + gh <= scrollTop && i + groupItemCount < size) {
prev += gh;
i += Math.min(groupItemCount, size - i) - 1;
i += groupItemCount - 1;
} else {
const ih = itemHeights.get(i);
if (prev + ih < container.scrollTop) prev += ih;
else {
if (prev + ih <= scrollTop && i < size - 1) {
prev += ih;
} else {
status = 1;
let visibleStartIndex = Math.max(0, i);
listStartIndex = Math.max(0, visibleStartIndex - visibleCount);
listEndIndex = Math.min(listStartIndex + visibleCount * 3, size);
i = listEndIndex - 1;
renderedList = list.slice(listStartIndex, listEndIndex);
for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j);
}
}
@ -435,10 +436,14 @@ const VirtualScroll = () => {
if (gh) {
post += gh;
i += groupItemCount - 1;
} else post += itemHeights.get(i);
} else {
post += itemHeights.get(i);
}
}
return { prevHeight: Math.max(0, prev - padTop - topMargin - rowGap), postHeight: post, renderedList: list.slice(listStartIndex, listEndIndex), listStartIndex };
}
const finalPrevHeight = Math.max(0, prev - padTop - topMargin - rowGap - (listStartIndex > 0 ? rowGap : 0));
const finalPostHeight = post > 0 ? Math.max(0, post - 2 * rowGap) : 0;
return { prevHeight: finalPrevHeight, postHeight: finalPostHeight, renderedList, listStartIndex };
}
};
};
@ -510,7 +515,7 @@ Component.register("List", (container) => {
if (container.fast) {
container.state._listStartIndex = 0;
container.state._renderedList = vs.reset(flatList, container) || [];
requestAnimationFrame(() => {
setTimeout(() => {
if (container.state._flatList === flatList) vs.init(flatList, container.refresh);
});
} else container.state._renderedList = flatList;
@ -533,7 +538,7 @@ Component.register("List", (container) => {
}, Util.makeDom(
/*html*/
`
<div class="list-group overflow-auto" $onscroll="this.fast && this.refresh()" style="overflow-anchor:none">
<div class="list-group overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
<div $if="this.fast && (this.state?.prevHeight || 0) > 0" $style="height:\${this.state?.prevHeight}px;" class="flex-shrink-0"></div>
<template slot-id="item" $each="this.state?._renderedList">
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item list-group-item-action d-inline-flex align-items-center ps-2 pe-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)">

2
dist/base.min.js vendored

File diff suppressed because one or more lines are too long

753
old/base.js Normal file
View File

@ -0,0 +1,753 @@
// base.js v2.3
(() => {
const { Component, NewState } = globalThis
// 工具函数
globalThis.Util = {
clone: window.structuredClone || (obj => JSON.parse(JSON.stringify(obj))),
base64: str => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
unbase64: str => new TextDecoder().decode(Uint8Array.from(atob(str), c => c.charCodeAt(0))),
urlbase64: str => $base64(str).replace(/[+/=]/g, m => ({ '+': '-', '/': '', '=': '' }[m])),
unurlbase64: str => $unbase64(str.replace(/[-_.]/g, m => ({ '-': '+', '_': '/', '.': '=' }[m])).padEnd(Math.ceil(str.length / 4) * 4, '=')),
safeJson: str => { try { return JSON.parse(str) } catch { return null } },
updateDefaults: (obj, defaults) => { for (const k in defaults) if (obj[k] === undefined) obj[k] = defaults[k] },
copyFunction: (toObj, fromObj, ...funcNames) => { funcNames.forEach(name => toObj[name] = fromObj[name].bind(fromObj)) },
getFunctionBody: fn => { const code = fn.toString(); return code.slice(code.indexOf('{') + 1, code.lastIndexOf('}')).trim() },
makeDom: html => {
if (html.includes('>\n')) html = html.replace(/>\s+</g, "><").trim()
const node = document.createElement('div')
node.innerHTML = html
return node.children[0]
},
newAvg: () => {
let total = 0, count = 0, avg = 0
return {
add: (v) => {
total += v
count++
return avg = total / count
},
get: () => avg,
clear: () => { total = 0, count = 0, avg = 0 }
}
},
newTimeCount: () => {
let startTime = 0, total = 0, count = 0
return {
start: () => startTime = new Date().getTime(),
end: () => {
const endTime = new Date().getTime()
const left = endTime - startTime
startTime = endTime
total += left
count++
return left
},
avg: () => total / count
}
},
}
// url hash 状态
let _hashParams = new URLSearchParams(window.location.hash?.substring(1) || '')
globalThis.Hash = NewState({}, k => Util.safeJson(_hashParams.get(k)), (k, v) => {
const oldStr = _hashParams.get(k)
const newStr = v === undefined ? undefined : JSON.stringify(v)
if (oldStr === newStr || (oldStr === null && newStr === undefined)) return
v === undefined ? _hashParams.delete(k) : _hashParams.set(k, newStr)
window.location.hash = '#' + _hashParams.toString()
})
window.addEventListener('hashchange', () => {
const oldHashParams = _hashParams
_hashParams = new URLSearchParams(window.location.hash?.substring(1) || '')
_hashParams.forEach((v, k) => { if (oldHashParams.get(k) !== v) Hash[k] = Util.safeJson(v) })
oldHashParams.forEach((v, k) => { if (_hashParams.get(k) === undefined) Hash[k] = undefined })
})
// localstorage 状态
globalThis.LocalStorage = NewState({}, k => Util.safeJson(localStorage.getItem(k)), (k, v) => {
const oldStr = localStorage.getItem(k)
const newStr = v === undefined ? undefined : JSON.stringify(v)
if (oldStr === newStr || (oldStr === null && newStr === undefined)) return
v === undefined ? localStorage.removeItem(k) : localStorage.setItem(k, newStr)
})
document.addEventListener('DOMContentLoaded', () => {
const htmlNode = document.documentElement
if (!htmlNode.hasAttribute('$data-bs-theme') && !htmlNode.hasAttribute('data-bs-theme')) htmlNode.setAttribute('$data-bs-theme', "LocalStorage.darkMode?'dark':'light'")
}, true)
// 页面退出状态
globalThis.State = NewState()
State.exitBlocks = 0
window.addEventListener('beforeunload', (event) => {
if (State.exitBlocks > 0) event.preventDefault()
})
// HTTP 和 API 组件
globalThis.HTTP = {
get: ({ url, ...opt }) => HTTP.request({ url, method: 'GET', ...opt }),
post: ({ url, data, ...opt }) => HTTP.request({ url, method: 'POST', data, ...opt }),
put: ({ url, data, ...opt }) => HTTP.request({ url, method: 'PUT', data, ...opt }),
delete: ({ url, ...opt }) => HTTP.request({ url, method: 'DELETE', ...opt }),
head: ({ url, ...opt }) => HTTP.request({ url, method: 'HEAD', ...opt }),
request: async ({ url, method = 'POST', data = undefined, headers = {}, responseType, timeout = 10000 }) => {
method = method.toUpperCase()
const options = { method, signal: AbortSignal.timeout?.(timeout) }
if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
if (data instanceof HTMLFormElement) data = new FormData(data)
if (data && typeof data === 'object' && !(data instanceof FormData) && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data)) && Object.values(data).some(v => v instanceof File || v instanceof Blob || v instanceof FileList || (Array.isArray(v) && v.some(i => i instanceof File || i instanceof Blob)))) {
const fd = new FormData()
for (const [k, v] of Object.entries(data)) {
if (v instanceof FileList || Array.isArray(v)) Array.from(v).forEach(item => fd.append(k, item))
else if (v !== undefined && v !== null) fd.append(k, v)
}
data = fd
}
if (data instanceof FormData) {
delete headers['Content-Type']
} else if (typeof data !== 'string' && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data))) {
data = JSON.stringify(data)
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'
}
options.body = data
}
if (Object.keys(headers).length) options.headers = headers
const response = { error: null, ok: null, status: 0, headers: {}, responseType: '', result: null }
try {
const resp = await fetch(url, options)
Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) })
if (!responseType) {
const contentType = resp.headers.get('Content-Type') || ''
if (contentType.includes('application/json')) responseType = 'json'
else if (/image|video|audio|pdf|zip|octet-stream/.test(contentType)) responseType = 'binary'
else responseType = 'text'
response.responseType = responseType
}
if (response.ok === false) response.error = (response.statusText || 'HTTP ' + response.status + ' error') + ' for ' + url
if (responseType === 'json') response.result = await resp.json()
else response.result = (responseType === 'binary') ? await resp.arrayBuffer() : await resp.text()
} catch (err) {
Object.assign(response, { error: err.message || String(err), ok: false })
}
return response
}
}
Component.register('API', container => {
container.request = NewState({ url: '', method: 'GET', headers: {}, data: null, timeout: 10000, responseType: '' })
container.response = NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: '', result: null })
container.result = NewState()
container.do = (opt = {}) => {
return new Promise((resolve, reject) => {
const req = { ...container.request, ...opt }
if (!req.url) throw new Error('.url is required')
req.headers = { ...container.request.headers, ...opt.headers }
container.response.loading = true
HTTP.request(req).then(resp => {
Object.keys(resp).forEach(k => { if (k !== 'result') container.response[k] = resp[k] })
if (resp.result && typeof resp.result === 'object' && container.result && typeof container.result === 'object') {
Object.assign(container.result, resp.result)
} else {
container.result = resp.result
}
container.response.loading = false
if (resp.ok === false) throw new Error(resp.error)
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
resolve(resp)
}).catch(err => {
if (!opt.noui) UI.toast(err.message, { type: 'danger' })
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
reject(err)
})
})
}
let _autoTimer = null
container.request.__watch(null, () => {
if (!container.hasAttribute('auto') || !container.request.url) return
if (_autoTimer) return
_autoTimer = Promise.resolve().then(() => {
container.do()
_autoTimer = null
})
})
})
globalThis.UI = {}
// 模态窗口和对话框组件
Component.register('Modal', container => {
container.modal = new bootstrap.Modal(container)
container.addEventListener('bind', e => {
e.detail ? container.modal.show() : container.modal.hide()
})
container.addEventListener('hide.bs.modal', () => {
document.activeElement?.blur()
container.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: false }))
})
Util.copyFunction(container, container.modal, 'show', 'hide')
}, Util.makeDom(/*html*/`
<div class="modal fade" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div $class="modal-content text-bg-\${this.state?.type || 'body'}">
<div slot-id="header" class="modal-header">
<h6 class="modal-title" $text="this.state?.title"></h6>
<button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="modal"></button>
</div>
<div slot-id="body" class="modal-body"></div>
<div slot-id="footer" class="modal-footer"></div>
</div>
</div>
</div>
`))
Component.register('Dialog', Component.getSetupFunction('Modal'), Util.makeDom(/*html*/`
<div class="modal fade" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div $class="modal-content text-bg-\${this.state?.type || 'body'}">
<div $if="this.state?.title" class="modal-header" $text="this.state?.title"></div>
<div slot-id="body" class="modal-body"><div $html="this.state?.message"></div></div>
<div class="modal-footer">
<button $each="this.state?.buttons || ['{#Close#}']" type="button" class="btn btn-sm btn-\${index === buttons.length - 1 ? (type && type !== 'body' ? type : 'primary') : 'secondary'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
</div>
</div>
</div>
</div>
`))
let _dialogCount = 0
UI.showDialog = function ({ title = '', message = '', buttons = ['{#Close#}'], type = 'body' }) {
const d = document.body.appendChild(document.createElement('Dialog'))
d.style.zIndex = 2000 + ++_dialogCount
Promise.resolve().then(() => {
Object.assign(d.state, { message, title, type, buttons })
d.show()
})
return new Promise((resolve) => {
d.addEventListener('change', e => {
_dialogCount--
resolve(d.result || 0)
d.remove()
})
})
}
UI.alert = function (message, options = {}) {
return UI.showDialog({ message, ...options })
}
UI.confirm = function (message, options = {}) {
return new Promise((resolve) => UI.showDialog({ message, buttons: ['{#Cancel#}', '{#Confirm#}'], ...options }).then(index => resolve(index >= 2)).catch(() => resolve(false)))
}
// 通知组件
Component.register('Toast', container => {
container.toast = new bootstrap.Toast(container, { autohide: container.state.delay > 0 })
Util.copyFunction(container, container.toast, 'show', 'hide')
container.addEventListener('show.bs.toast', () => {
if (container.state.delay > 0) {
let timer
const startTimer = () => {
container.state.left = container.state.delay / 1000
timer = setInterval(() => {
if (!container.isConnected || --container.state.left <= 0) clearInterval(timer)
}, 1000)
}
startTimer()
container.addEventListener('mouseenter', () => { clearInterval(timer); container.state.left = undefined })
container.addEventListener('mouseleave', startTimer)
}
})
}, Util.makeDom(/*html*/`
<div class="toast align-items-center border-0 m-1">
<div $class="toast-body p-3 text-bg-\${this.state?.type}">
<div class="d-flex align-items-center">
<div class="flex-grow-1">
<span style="white-space:pre-wrap" class="fs-6" $text="this.state?.message"></span>
<span $if="this.state?.left !== undefined" class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
</div>
<button type="button" class="btn btn-link ms-3 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="toast"></button>
</div>
<div class="d-flex justify-content-end gap-3">
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
</div>
</div>
</div>
`), Util.makeDom(/*html*/`<div toast-container="default" class="position-fixed bottom-0 end-0 overflow-auto" style="z-index:3000;max-height:80%"></div>`))
UI.toast = function (message, options = {}) {
const delay = options.delay ?? 5000
const t = document.createElement('Toast')
t.state = { delay, left: delay ? delay / 1000 : undefined, type: options.type || 'body', message, buttons: options.buttons || [] }
$(`[toast-container="${options.container || 'default'}"]`).appendChild(t)
Promise.resolve().then(() => t.show())
}
UI.toastConfirm = function (message, options = {}) {
return new Promise((resolve) => UI.toast(message, { buttons: ['{#Confirm#}'], ...options }).then(index => resolve(index === 1)).catch(() => resolve(false)))
}
Component.register('AutoForm', container => {
if (!container.schema) container.schema = []
container.vertical = container.hasAttribute('vertical')
container.request = { method: 'POST' }
container.response = {}
container.result = null
container.data = NewState(container.data || {})
container.form = $(container, 'form')
container.submit = (opt = {}) => {
if (!container.form.reportValidity()) return UI.toast('{#verify failed#}', { type: 'danger' })
if (!container.dispatchEvent(new CustomEvent('submit', { detail: container.data, cancelable: true, bubbles: false }))) return
const req = { ...container.request, data: container.data, noui: true, ...opt }
let task = null
if (container.api) task = container.api.do(req)
else if (container.request.url) task = HTTP.request(req)
else return console.warn('{#please config .api or .request.url to auto submit#}')
task.then(resp => {
container.response = resp
container.result = resp.result
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
}).catch(err => {
UI.toast(err.message, { type: 'danger' })
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
})
}
}, Util.makeDom(/*html*/`
<div>
<form $class="align-items-center \${this.vertical?'':'auto-grid-form'}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
<div $each="this.schema || []" style="display:contents">
<label $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
<div control-wrapper class="mb-3">
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" class="form-control" $type="item.type" $.="item.setting" $bind="this.data[item.name]">
<select $if="item.type === 'select'" $name="item.name" class="form-select" $.="item.setting" $bind="this.data[item.name]">
<option value="" $if="item.placeholder" $text="item.placeholder" disabled selected></option>
<option $each="item.options" $value="item.value || item" $text="item.label || item"></option>
</select>
<div $if="['checkbox', 'radio'].includes(item.type)" >
<label $each="item.options || [item.text||item.label||item.name]" as="option" $class="form-check\${item.vertical ? '' : ' form-check-inline'}">
<input $name="item.name" class="form-check-input" $type="item.type" $.="item.setting" $value="item.options?option:'on'" $bind="this.data[item.name]">
<span $text="option" class="form-check-label"></span>
</label>
</div>
<div $if="item.type==='switch'" class="form-check form-switch fs-4"><input $name="item.name" class="form-check-input my-0" type="checkbox" $bind="this.data[item.name]"></div>
<textarea $if="item.type==='textarea'" $name="item.name" class="form-control" $.="item.setting" $bind="this.data[item.name]"></textarea>
</div>
</div>
<div class="d-flex justify-content-end align-items-baseline gap-3 mt-3" style="grid-column:1/-1">
<div slot-id="actions"></div>
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
</div>
</form>
</div>
`), Util.makeDom(/*html*/`<style>@media (min-width: 576px) { .auto-grid-form {display: grid;grid-template-columns: max-content 1fr} .auto-grid-form .col-form-label {text-align: right; margin-bottom: 1rem;padding-right: 1rem;max-width: 200px} }</style>`))
const _pendingAutoFormComponents = []
globalThis.AutoForm = {
register: name => {
if (document.readyState !== 'loading') AutoForm._addAutoFormComponent(name)
else _pendingAutoFormComponents.push(name)
},
_addAutoFormComponent: name => {
$(Component.getTemplate('AutoForm')?.content, '[control-wrapper]').appendChild(Util.makeDom(`<${name} $if="item.type.toUpperCase() === '${name.toUpperCase()}'" $name="item.name" $.="item.setting" $bind="this.data[item.name]"></${name}>`))
}
}
document.addEventListener('DOMContentLoaded', () => {
_pendingAutoFormComponents.forEach(name => AutoForm._addAutoFormComponent(name))
_pendingAutoFormComponents.length = 0
}, true)
Component.register('TagsInput', container => {
container.addEventListener('bind', e => {
container.state.tags = e.detail || []
})
}, Util.makeDom(/*html*/`
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text">
<button $each="this.state.tags" type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${Util.getFunctionBody(event => {
if (['Backspace', 'Delete'].includes(event.key)) {
event.preventDefault()
this.state.tags.splice(index, 1)
this.state.tags = this.state.tags
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags }))
Promise.resolve().then(() => {
const buttons = $$(this, 'button');
(buttons.length > 0 ? buttons[index > 0 ? index - 1 : 0] : $(this, 'input')).focus()
})
}
})}" $text="item"></button>
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onkeydown="${Util.getFunctionBody(event => {
if (event.isComposing) return
if (['Enter', ',', ' '].includes(event.key)) {
event.preventDefault()
const v = thisNode.value.trim()
if (v && !this.state.tags.includes(v)) {
this.state.tags.push(v)
this.state.tags = this.state.tags
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags }))
}
thisNode.value = ''
}
})}">
</div>
`), Util.makeDom(/*html*/`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>`))
AutoForm.register('TagsInput')
Component.register('FastList', container => {
const itemHeights = new Map()
const groupHeights = new Map()
let groupItemCount = 1
container.state.renderedList = []
const avg = Util.newAvg()
let containerPaddingTop = 0
let containerRowGap = 0
let topMargin = 0
let listStartIndex = 0
let visibleStartIndex = 0
let visibleCount = 10
let listInited = false
container.onItemUpdate = (index, node) => {
const absoluteIndex = index + listStartIndex
const oldHeight = itemHeights.get(absoluteIndex)
const style = window.getComputedStyle(node)
const marginTop = parseFloat(style.marginTop) || 0
const marginBottom = parseFloat(style.marginBottom) || 0
const newHeight = node.offsetHeight + marginTop + marginBottom + containerRowGap
if (absoluteIndex === 0 && !topMargin) topMargin = marginTop
if (newHeight !== oldHeight) {
itemHeights.set(absoluteIndex, newHeight)
avg.add(newHeight)
const offset = newHeight - oldHeight || 0
const groupIndex = absoluteIndex - (absoluteIndex % groupItemCount)
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset)
}
}
// const timeCount = Util.newTimeCount()
container.refresh = () => {
if (!listInited) return
// timeCount.start()
visibleCount = Math.ceil((container.clientHeight || 100) / (avg.get() || 32))
let list = []
const fullList = container.state.list
const fullSize = fullList.length
let i = 0
let prev = containerPaddingTop + topMargin + containerRowGap
let post = 0
let starus = 0
for (i = 0; i < fullSize; i++) {
if (starus === 0) {
// 可视位置之前的计算
const gh = groupHeights.get(i)
if (gh && prev + gh < container.scrollTop) {
prev += gh
i += Math.min(groupItemCount, fullSize - i) - 1
} else {
const ih = itemHeights.get(i)
if (prev + ih < container.scrollTop) {
prev += ih
} else {
starus = 1
// 可视位置处理
visibleStartIndex = Math.max(0, i)
listStartIndex = Math.max(0, visibleStartIndex - visibleCount)
const listEndIndex = Math.min(listStartIndex + visibleCount * 3, fullSize)
i = listEndIndex - 1
list = fullList.slice(listStartIndex, listEndIndex)
for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j)
}
}
} else if (starus === 1) {
// 底部填充计算
const gh = groupHeights.get(i)
if (gh) {
post += gh
i += groupItemCount - 1
} else {
post += itemHeights.get(i)
}
}
}
// console.info('FastList refresh make list:', timeCount.end())
container.state.prevHeight = prev - containerPaddingTop - topMargin - containerRowGap
container.state.postHeight = post
container.state.renderedList = list
// console.info('FastList refresh time:', timeCount.end())
}
container.state.__watch('list', list => {
listInited = false
itemHeights.clear()
groupHeights.clear()
avg.clear()
if (!list?.length) return
const listSize = list.length || 0
groupItemCount = Math.ceil(Math.sqrt(listSize)) || 10
const style = window.getComputedStyle(container)
containerPaddingTop = parseFloat(style.paddingTop)
containerRowGap = parseFloat(style.rowGap) || 0
container.state.renderedList = list.slice(0, Math.min(30, listSize)) || []
setTimeout(() => {
// 渲染一批之后立刻计算所有高度
container.state.list.forEach((item, index) => itemHeights.set(index, avg.get()))
for (let i = 0; i < listSize; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, listSize - i) * avg.get())
listInited = true
container.refresh()
})
})
}, Util.makeDom(/*html*/`
<div class="overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
<div $if="this.state.prevHeight>0" $style="height:\${this.state.prevHeight}px;" class="flex-shrink-0"></div>
<div slot-id="item" class="flex-shrink-0" $each="this.state.renderedList" $onupdate="this.onItemUpdate(index,thisNode)"></div>
<div $if="this.state.postHeight>0" $style="height:\${this.state.postHeight}px;" class="flex-shrink-0"></div>
</div>
`))
Component.attachSelectFeature = (container, eventName = 'itemclick', selectActionName = 'selectItem', activeActionName = 'itemActiveTag', selectedItemKey = 'selectedItem', idfieldKey = 'idfield', selectableAttr = 'auto-select') => {
const selectable = selectableAttr && container.hasAttribute(selectableAttr)
container[selectActionName] = (item, index) => {
if (selectable) container.state[selectedItemKey] = container.state[selectedItemKey] === item[container[idfieldKey]] ? null : item[container[idfieldKey]]
container.dispatchEvent(new CustomEvent(eventName, { bubbles: false, detail: !selectable || container.state[selectedItemKey] ? { item, index } : { item: {}, index: null } }))
}
container[activeActionName] = (item) => container.state[selectedItemKey] !== null && container.state[selectedItemKey] === item[container[idfieldKey]] ? 'active' : ''
}
Component.register('List', container => {
Component.attachSelectFeature(container)
const defaultSets = { idfield: 'id', labelfield: 'label', summaryfield: 'summary' }
container.state.__watch('list', oldList => {
Util.updateDefaults(container, defaultSets)
})
}, Util.makeDom(/*html*/`
<div class="list-group overflow-auto">
<div slot="item" slot-id="item" $each="this.state.list" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action list-group-item-heading d-inline-flex pe-auto align-items-baseline \${this.itemActiveTag?.(item)}">
<span $if="this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</div>
`))
Component.register('GroupedList', container => {
Component.getSetupFunction('List')(container)
container._selectItem = container.selectItem
container.selectItem = (item, index) => {
container.state.selectedGroup = null
container._selectItem(item, index)
}
Component.attachSelectFeature(container, 'groupclick', 'selectGroup', 'groupActiveTag', 'selectedGroup', 'groupidfield', container.hasAttribute('auto-select') && 'group-selectable')
container._selectGroup = container.selectGroup
container.selectGroup = (item, index) => {
container.state.selectedItem = null
container._selectGroup(item, index)
}
const defaultSets = { typefield: 'type', groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group' }
container.state.__watch('list', oldList => {
Util.updateDefaults(container, defaultSets)
const newList = [];
(container.state.groups || []).forEach(group => {
newList.push({ [container.typefield]: 'group', ...group })
if (oldList) oldList.forEach(item => {
if (item[container.groupfield] === group[container.groupidfield]) {
newList.push({ [container.typefield]: 'item', ...item })
}
})
})
aa = container
return newList
})
}, Util.makeDom(/*html*/`
<div class="list-group overflow-auto">
<template $each="this.state.list">
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div>
<div slot-id="group-actions"></div>
</div>
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</template>
</div>
`))
Component.register('FastGroupedList', container => {
Component.getSetupFunction('List')(container)
Component.getSetupFunction('GroupedList')(container)
}, Util.makeDom(/*html*/`
<FastList class="list-group">
<div slot="item">
<div slot-id="group" $if="item.type === 'group'" $onclick="this.selectGroup(item,index)" style="height: 36px" $class="list-group-item list-group-item-action small d-inline-flex align-items-center ps-2 pe-2 \${this.groupActiveTag(item)}">
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.grouplabelfield]}</div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.groupsummaryfield]"></div>
<div slot-id="group-actions"></div>
</div>
<div slot-id="item" $if="item.type === 'item'" $onclick="this.selectItem(item,index)" $class="list-group-item list-group-item-action d-inline-flex align-items-baseline ps-3 pe-2 \${this.itemActiveTag?.(item)}">
<span $if="this.itemicon" $class="bi bi-\${this.itemicon} text-body"></span>
<div class="flex-shrink-0 px-1" $text>\${item[this.labelfield]}</div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</div>
</FastList>
`))
Component.register('Tree', container => {
const defaultSets = { parentfield: 'parent', groupicon: 'folder', itemicon: 'file' }
container.state.__watch('list', () => {
Util.updateDefaults(container, defaultSets)
const childrenMap = {};
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
const newList = []
const traverse = (list, level) => list.forEach(item => {
const id = item[container.idfield]
const hasChildren = !!childrenMap[id]?.length
newList.push({ ...item, _level: level, _hasChildren: hasChildren })
if (hasChildren) traverse(childrenMap[id], level + 1)
})
traverse(childrenMap[''] || [], 0)
return newList
})
}, Util.makeDom(/*html*/`
<List>
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]+'!!'"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</List>
`))
Component.register('FastTree', container => {
Component.getSetupFunction('List')(container)
Component.getSetupFunction('Tree')(container)
}, Util.makeDom(/*html*/`
<FastList class="list-group list-group-action">
<div slot="item" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16}px" class="flex-shrink-0"></div>
<div $class="text-muted bi bi-\${item._hasChildren?this.groupicon:this.itemicon}"></div>
<div class="flex-grow-1 text-truncate px-2" $text="item[this.labelfield]"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</FastList>
`))
Component.register('CollapseTree', container => {
Component.attachSelectFeature(container)
const defaultSets = { idfield: 'id', parentfield: 'parent', labelfield: 'label', summaryfield: 'summary' }
container.collapsed = NewState({})
container.state.__watch('list', () => {
Util.updateDefaults(container, defaultSets)
const childrenMap = {};
(container.state.list || []).forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
const newList = []
const traverse = (list, level, parents) => list.forEach(item => {
const id = item[container.idfield]
const hasChildren = !!childrenMap[id]?.length
newList.push({ ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
if (hasChildren) traverse(childrenMap[id], level + 1, [...parents, id])
})
traverse(childrenMap[''] || [], 0, [])
return newList
})
}, Util.makeDom(/*html*/`
<List>
<div slot="item" slot-id="item" $if="!item._parents.some(cur=>this.collapsed[cur])" $class="list-group-item list-group-item-action d-flex ps-2 align-items-center \${this.itemActiveTag?.(item)}" $onclick="this.selectItem(item,index)">
<div $style="width:\${item._level * 16 + 16}px; cursor: pointer;" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation();this.collapsed[item[this.idfield]] = !this.collapsed[item[this.idfield]]">
<i $if="item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
</div>
<div class="flex-grow-1 text-truncate px-1" $text="item[this.labelfield]"></div>
<div class="text-muted small flex-fill text-end text-truncate" style="width: 0" $text="item[this.summaryfield]"></div>
<div slot-id="item-actions"></div>
</div>
</List>
`))
Component.register('Nav', container => {
container.click = (item, noselect) => {
if (!item.noselect && !noselect) Hash.nav = item.name
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
}
}, Util.makeDom(/*html*/`
<div class="navbar navbar-expand bg-body-secondary px-3 pb-0 border-bottom align-items-center">
<img $if="this.state.brand.image" $src="this.state.brand.image" class="me-2" style="height:30px;width:auto;max-width:300px">
<i $if="this.state.brand.icon" $class="bi bi-\${this.state.brand.icon} me-2"></i>
<span $if="this.state.brand.label" class="me-2" $text="this.state.brand.label"></span>
<div class="ms-2"></div>
<div $each="this.state.list" $class="navbar-nav text-truncate \${item.type==='fill'?'flex-fill':''}">
<button $if="item.type==='button'" $class="nav-link \${Hash.nav===item.name?'active':''}" $onclick="this.click(item)">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="d-none d-\${this.state.list.length>5?'lg':'md'}-inline" $text="item.label"></span>
</button>
<div $if="item.type==='dropdown'" class="dropdown">
<button $class="nav-link \${Hash.nav===item.name?'active':''}" data-bs-toggle="dropdown">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="d-none d-\${this.state.list.length>5?'lg':'md'}-inline" $text="item.label"></span>
</button>
<div class="dropdown-menu dropdown-menu-end p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
<template $each="item.list" as="subitem">
<button $if="subitem.type==='button'" class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
</button>
<div $if="subitem.type==='switch'" class="d-flex align-items-center">
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
<div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
</div>
</template>
</div>
</div>
</div>
</div>
`))
let _mouseMoverMoving = false
let _mouseMoverPos = {}
let _mouseMoverEvents = {}
globalThis.MouseMover = {
start: (event, { onmousemove, onmouseup }) => {
_mouseMoverPos = { x: event.clientX, y: event.clientY, w: 0, h: 0 }
_mouseMoverEvents = { onmousemove, onmouseup }
_mouseMoverMoving = true
},
}
document.addEventListener('mouseup', event => {
if (!_mouseMoverMoving) return
_mouseMoverMoving = false
_mouseMoverEvents.onmouseup?.({ event, ..._mouseMoverPos })
})
document.addEventListener('mousemove', event => {
if (!_mouseMoverMoving) return
_mouseMoverPos.w = event.clientX - _mouseMoverPos.x
_mouseMoverPos.h = event.clientY - _mouseMoverPos.y
_mouseMoverEvents.onmousemove?.({ event, ..._mouseMoverPos })
})
Component.register('Resizer', container => {
const isVertical = container.hasAttribute('vertical')
const min = parseInt(container.getAttribute('min')) || 10
const max = parseInt(container.getAttribute('max')) || 1000
const target = container.target || container.previousElementSibling
const getSize = (startSize, w, h) => {
const newSize = startSize + (isVertical ? h : w)
return newSize < min ? min : newSize > max ? max : newSize
}
container.addEventListener('mousedown', event => {
const startSize = isVertical ? target.offsetHeight : target.offsetWidth
MouseMover.start(event, {
onmousemove: ({ w, h }) => target.style[isVertical ? 'height' : 'width'] = getSize(startSize, w, h) + 'px',
onmouseup: ({ w, h }) => container.dispatchEvent(new CustomEvent('resize', { detail: { oldSize: startSize, newSize: getSize(startSize, w, h) }, bubbles: false })),
})
})
}, Util.makeDom(/*html*/`
<div $class="border-\${this.isVertical?'top':'start'}" $style="\${this.isVertical?'height':'width'}:3px;cursor:\${this.isVertical?'row-resize':'col-resize'}"></div>
`))
})()

View File

@ -20,11 +20,7 @@ export const VirtualScroll = () => {
if (listInited) return;
const size = list.length, defaultHeight = avg.get() || 32;
for (let i = 0; i < size; i++) if (!itemHeights.has(i)) itemHeights.set(i, defaultHeight);
for (let i = 0; i < size; i += groupItemCount) {
let sum = 0;
for (let j = i; j < Math.min(i + groupItemCount, size); j++) sum += itemHeights.get(j);
groupHeights.set(i, sum);
}
for (let i = 0; i < size; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, size - i) * defaultHeight);
listInited = true; refreshCallback();
},
update: (absoluteIndex, node) => {
@ -32,7 +28,7 @@ export const VirtualScroll = () => {
const style = window.getComputedStyle(node);
itemMarginTop = parseFloat(style.marginTop) || 0; itemMarginBottom = parseFloat(style.marginBottom) || 0;
}
if (absoluteIndex === 0) topMargin = itemMarginTop;
if (absoluteIndex === 0 && !topMargin) topMargin = itemMarginTop;
const newHeight = node.offsetHeight + itemMarginTop + itemMarginBottom + rowGap;
const oldHeight = itemHeights.get(absoluteIndex);
if (newHeight !== oldHeight) {
@ -43,31 +39,49 @@ export const VirtualScroll = () => {
},
calc: (container, list) => {
if (!listInited || !list) return null;
const size = list.length, visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (avg.get() || 32)));
const size = list.length;
let visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (avg.get() || 32)));
let prev = padTop + topMargin + rowGap, post = 0, status = 0, listStartIndex = 0, listEndIndex = 0;
let renderedList = [];
const scrollTop = container.scrollTop;
for (let i = 0; i < size; i++) {
if (status === 0) {
const gh = groupHeights.get(i);
if (gh && prev + gh <= scrollTop && i + groupItemCount < size) { prev += gh; i += groupItemCount - 1; }
else {
if (gh && prev + gh <= scrollTop && (i + groupItemCount < size)) {
prev += gh;
i += groupItemCount - 1;
} else {
const ih = itemHeights.get(i);
if (prev + ih <= scrollTop && i < size - 1) prev += ih;
else {
status = 1; let visibleStartIndex = i;
if (prev + ih <= scrollTop && i < size - 1) {
prev += ih;
} else {
status = 1;
let visibleStartIndex = Math.max(0, i);
listStartIndex = Math.max(0, visibleStartIndex - visibleCount);
listEndIndex = Math.min(listStartIndex + visibleCount * 3, size);
i = listEndIndex - 1; for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j);
i = listEndIndex - 1;
renderedList = list.slice(listStartIndex, listEndIndex);
for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j);
}
}
} else {
} else if (status === 1) {
const gh = groupHeights.get(i);
if (gh && i + groupItemCount <= size) { post += gh; i += groupItemCount - 1; }
else post += itemHeights.get(i);
if (gh) {
post += gh;
i += groupItemCount - 1;
} else {
post += itemHeights.get(i);
}
}
return { prevHeight: Math.max(0, prev - padTop - topMargin - rowGap), postHeight: post, renderedList: list.slice(listStartIndex, listEndIndex), listStartIndex };
}
// Fix for flex gap inflation:
// prevHeight block participates in flex gap, so we subtract one rowGap.
const finalPrevHeight = Math.max(0, prev - padTop - topMargin - rowGap - (listStartIndex > 0 ? rowGap : 0));
// post accumulated M * (h + gap). Real space is sum(h) + (M-1)*gap = post - gap.
// DOM adds one gap before the block, so we need postHeight + gap = post - gap => postHeight = post - 2*gap.
const finalPostHeight = post > 0 ? Math.max(0, post - 2 * rowGap) : 0;
return { prevHeight: finalPrevHeight, postHeight: finalPostHeight, renderedList, listStartIndex };
}
}
}
@ -130,7 +144,7 @@ Component.register('List', container => {
if (container.fast) {
container.state._listStartIndex = 0
container.state._renderedList = vs.reset(flatList, container) || []
requestAnimationFrame(() => { if (container.state._flatList === flatList) vs.init(flatList, container.refresh) })
setTimeout(() => { if (container.state._flatList === flatList) vs.init(flatList, container.refresh) })
} else container.state._renderedList = flatList
})
@ -146,7 +160,7 @@ Component.register('List', container => {
updateFlatList()
}, Util.makeDom(/*html*/`
<div class="list-group overflow-auto" $onscroll="console.log('scroll event fired', thisNode.scrollTop); thisNode.fast && thisNode.refresh()" style="overflow-anchor:none">
<div class="list-group overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
<div $if="this.fast && (this.state?.prevHeight || 0) > 0" $style="height:\${this.state?.prevHeight}px;" class="flex-shrink-0"></div>
<template slot-id="item" $each="this.state?._renderedList">
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item list-group-item-action d-inline-flex align-items-center ps-2 pe-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)">

View File

@ -14,6 +14,8 @@ test('base project comprehensive tests and scrolling benchmarks', async ({ page
// Simulate real scrolling and check dynamic rendering for FastList
console.log('Simulating real scroll on FastList...');
const scrollInfo = await page.evaluate(async () => {
window.switchTab?.('ll');
await new Promise(r => setTimeout(r, 200)); // wait for render
const el = document.getElementById('ll');
const results = [];

View File

@ -48,6 +48,8 @@ export async function runTests() {
const listIds = ['ll', 'gl', 'tt', 'ct'];
for (const id of listIds) {
window.switchTab?.(id);
await new Promise(r => setTimeout(r, 100)); // wait for render
const el = document.getElementById(id);
if (!el) throw new Error(`Component #${id} not found`);
console.log(`Component #${id} tagName:`, el.tagName, 'has refresh:', !!el.refresh, 'setupFunc exists:', !!Component.getSetupFunction(el.tagName));
@ -59,6 +61,8 @@ export async function runTests() {
window.benchResults = {};
const measure = async (id, name) => {
window.switchTab?.(id);
await new Promise(r => setTimeout(r, 100)); // wait for render
const el = document.getElementById(id);
const start = performance.now();
el.scrollTop = 5000;

View File

@ -27,8 +27,23 @@
group_list.push({ id: i, label: `group ${i}`, summary: `group ${i} summary` })
}
</script>
<div class="d-flex flex-fill flex-wrap overflow-auto">
<div class="p-2 h-50 w-50 d-flex flex-column">
<script>
function switchTab(tabId) {
document.querySelectorAll('.list-container').forEach(el => el.style.display = 'none');
document.getElementById('container-' + tabId).style.display = 'flex';
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.getElementById('btn-' + tabId).classList.add('active');
window.activeTab = tabId;
}
</script>
<div class="p-2 border-bottom d-flex gap-2">
<button id="btn-ll" class="tab-btn btn btn-sm btn-outline-primary active" onclick="switchTab('ll')">Fast List</button>
<button id="btn-gl" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('gl')">Fast Grouped</button>
<button id="btn-tt" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('tt')">Fast Tree</button>
<button id="btn-ct" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('ct')">Normal Tree</button>
</div>
<div class="d-flex flex-fill p-2 overflow-hidden">
<div id="container-ll" class="list-container flex-fill d-flex flex-column overflow-hidden">
<h5>Fast List (Variable Height)</h5>
<List fast id="ll" auto-select class="flex-fill d-flex flex-column gap-3 bg-body-secondary rounded" $.state.list="list_data" $onitemclick="console.log(index, item)">
<template slot="item">
@ -36,17 +51,17 @@
</template>
</List>
</div>
<div class="p-2 h-50 w-50 d-flex flex-column">
<div id="container-gl" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
<h5>Fast Grouped List</h5>
<List fast mode="group" id="gl" auto-select auto-select-group class="flex-fill d-flex flex-column border border-info rounded" $.state.groups="group_list" $.state.list="list_data"
$ongroupclick="console.log(index, item)">
</List>
</div>
<div class="p-2 h-50 w-50 d-flex flex-column">
<div id="container-tt" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
<h5>Fast Tree List</h5>
<List fast mode="tree" id="tt" auto-select class="flex-fill d-flex flex-column border border-info rounded" $.state.list="list_data" $onitemclick="console.log(index, item)"></List>
</div>
<div class="p-2 h-50 w-50 d-flex flex-column">
<div id="container-ct" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
<h5>Normal Tree List (Collapsible)</h5>
<List mode="tree" collapsible id="ct" auto-select class="flex-fill d-flex flex-column border border-info rounded" $.state.list="list_data.slice(0, 1200)" $onitemclick="console.log(index, item)">
</List>