Compare commits

...

3 Commits

29 changed files with 10853 additions and 1328 deletions

View File

@ -6,7 +6,7 @@
### 1. 导出与全局挂载
- **模块导出**`index.js` 重新导出了 `http.js`, `ui.js`, `form.js`, `controls.js`, `list.js`, `nav.js`, `interaction.js` 中的所有对象,以及来自 `@apigo.cc/state``State`
- **全局命名空间挂载**:在 `window/globalThis` 上挂载了 `HTTP`, `UI`, `AutoForm`, `MouseMover`, `VirtualScroll`, `ApigoBase`
- **初始化自动刷新**:页面加载时(`DOMContentLoaded` 或立即执行),对 `document.documentElement` 执行 `RefreshState()`。
- **初始化自动刷新**:页面加载时(`DOMContentLoaded` 或立即执行),对 `document.documentElement` 执行 `__RefreshState_Internal_Force_Full_Scan_Only_In_Extreme_Performance_Scenarios()`。
### 2. 退出拦截逻辑
- **拦截触发条件**:当 `State.exitBlocks > 0` 时,通过 `window.addEventListener('beforeunload')` 拦截页面刷新或关闭(调用 `event.preventDefault()`)。

View File

@ -1,5 +1,19 @@
# Changelog
## [1.0.13] - 2026-06-08
### Fixed
- **State Integration**: Bumped `@apigo.cc/state` to `1.0.18` to restore the globally injected `$`/`$$` helpers from the original UMD era, fixing `TagsInput` element targeting.
- **AutoForm Layout**: Fixed `horizontal` grid layout breakage caused by missing `AUTOFORM_STYLE` registration.
- **AutoForm Expansion**: Restored `AutoForm.register` with deep-blueprint `[control-wrapper]` physical insertion mechanism, fixing the blank rendering bug of `TagsInput` and `ColorPicker`.
- **Form Controls**: Removed `height: 100%` from `ColorPicker` preventing flex squash; fixed horizontal mode left-padding overflow for Check/Radio/Switch components.
- **Build**: Fully expunged `.mjs` artifacts from vite build pipeline to enforce UMD compliance.
## [1.0.12] - 2026-06-05
### Changed
- **De-ESM Refactor**: Fully transitioned the runtime environment to synchronous UMD loading, eliminating race conditions and rendering artifacts.
- **AutoForm Purification**: Purged all "black-box" patching logic that dynamically modified component templates. Field rendering now follows 100% transparent, explicit TEMPLATE logic.
- **Philosophy Alignment**: Removed all forbidden `RefreshState` calls from business logic, entrusting the asynchronous `MutationObserver` engine for all UI updates.
## [1.0.11] - 2026-06-05
### Changed
- Dependency: Updated `@apigo.cc/state` CDN script dependency to `1.0.16` for AutoForm nested field mounting bindings fix.

View File

@ -6,19 +6,25 @@
## 1. 快速集成 (Quick Start)
### CDN 集成 (自包含 UMD锁定版本号无需引入 Bootstrap CSS)
### 同步 UMD 集成 (推荐:消灭异步时序风险,实现“秒开”渲染)
将脚本放置在 `<head>` 中,确保地基在 DOM 解析前就绪:
```html
<!-- 依赖核心库 -->
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/state@1.0.16/dist/state.min.js"></script>
<!-- Bootstrap 集成引擎 (自包含 CSS 注入) -->
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/bootstrap@1.0.5/dist/bootstrap.min.js"></script>
<!-- 本组件库 -->
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/base@1.0.10/dist/base.min.js"></script>
<!-- 1. 基础状态机 (地基) -->
<script src="dist/lib/state.js"></script>
<!-- 2. Bootstrap 适配层 -->
<script src="dist/lib/bootstrap.js"></script>
<!-- 3. 本业务组件库 -->
<script src="dist/lib/base.js"></script>
<script>
// 4. 数据先行 (在 body 解析前定义)
window.brand = { label: 'My App' };
</script>
```
### ESM 模块引入
### ESM 模块引入 (Legacy)
```javascript
import { HTTP, UI, AutoForm, State } from '@apigo.cc/base'
const { HTTP, UI, AutoForm, State } = window.ApigoBase
```
---
@ -93,7 +99,7 @@ import { HTTP, UI, AutoForm, State } from '@apigo.cc/base'
<!-- 2. HTML 声明式属性绑定 schema 状态,且绑定接口 API 实例 -->
<AutoForm id="employeeForm"
vertical
$state.schema="employeeFormSchema"
$.state.schema="employeeFormSchema"
$api="saveEmployeeApi"
$onresponse="UI.toast('保存成功!')">
</AutoForm>
@ -151,7 +157,7 @@ import { HTTP, UI, AutoForm, State } from '@apigo.cc/base'
```
* **HTML 写法**
```html
<List mode="group" $list="groupUserList" $state.groups="deptGroups"></List>
<List mode="group" $list="groupUserList" $.state.groups="deptGroups"></List>
```
#### 模式三:树形可折叠列表 (`mode="tree"`) —— 超级示例
@ -216,8 +222,8 @@ import { HTTP, UI, AutoForm, State } from '@apigo.cc/base'
<!-- 2. HTML 声明式属性绑定 -->
<Nav id="sidebarNav"
vertical
$state.brand="myBrand"
$state.list="myNavList"
$.state.brand="myBrand"
$.state.list="myNavList"
$onnav="console.log('导航点击:', event.detail.item)">
</Nav>
```
@ -283,6 +289,7 @@ const resp = await HTTP.post({
## 4. 开发红线 (Constraints)
1. **表单数据操作红线**:严禁直接覆盖表单的 `state.data``data` 对象(如 `form.data = {}`)。这会切断与内部 Proxy 的响应式链路。必须使用 `Object.assign(form.state.data, newData)`
2. **列表布局红线**:开启虚拟滚动(`fast`)时,容器必须包含 `overflow-auto` 类。
3. **指令 DOM 保护**:严禁使用原生 DOM API 直接修改由 `$each``$if` 或组件渲染指令生成的 DOM 节点。所有 DOM 状态的变化应当完全通过修改与之绑定的底层 `State` 属性驱动。
4. **退出拦截约束**:在全局配置有 `State.exitBlocks > 0` 时,框架将强行拦截并警告任何刷新/关闭页面的行为。
2. **结构化指令红线**:所有 `$if``$each` 指令 **必须** 显式作用于 `<template>` 标签,且严禁在同一个 `<template>` 上同时使用两者(必须嵌套)。
3. **列表布局红线**:开启虚拟滚动(`fast`)时,容器必须包含 `overflow-auto` 类。
4. **指令 DOM 保护**:严禁使用原生 DOM API 直接修改由 `$each``$if` 或组件渲染指令生成的 DOM 节点。所有 DOM 状态的变化应当完全通过修改与之绑定的底层 `State` 属性驱动。
5. **退出拦截约束**:在全局配置有 `State.exitBlocks > 0` 时,框架将强行拦截并警告任何刷新/关闭页面的行为。

378
dist/base.js vendored

File diff suppressed because one or more lines are too long

2
dist/base.min.js vendored

File diff suppressed because one or more lines are too long

1
dist/base.min.mjs vendored

File diff suppressed because one or more lines are too long

920
dist/base.mjs vendored

File diff suppressed because one or more lines are too long

753
old/base_original.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

@ -1,9 +1,8 @@
{
"name": "@apigo.cc/base",
"version": "1.0.11",
"version": "1.0.13",
"type": "module",
"main": "dist/base.js",
"module": "dist/base.js",
"files": [
"dist"
],

7
src/controls.js vendored
View File

@ -6,6 +6,7 @@ import { AutoForm } from './form.js'
* 支持单日期及范围选择 (主字段 + 影子字段模式)
*/
Component.register('DatePicker', container => {
container._thisObj = container;
container.state = NewState({ start: '', end: '' })
container.addEventListener('bind', e => {
@ -63,6 +64,7 @@ Component.register('DatePicker', container => {
* 支持颜色选择与十六进制文本输入
*/
Component.register('ColorPicker', container => {
container._thisObj = container;
container.state = NewState({ value: '#000000' })
container.addEventListener('bind', e => {
container.state.value = e.detail || '#000000'
@ -77,8 +79,8 @@ Component.register('ColorPicker', container => {
}
}, Util.makeDom(/*html*/`
<div class="d-flex align-items-center gap-2 w-100 h-100">
<input type="color" class="form-control form-control-color" style="width: 3rem; height: 100%; padding: 0.25rem" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">
<input type="text" class="form-control h-100" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">
<input type="color" class="form-control form-control-color flex-shrink-0" style="width: 3rem; padding: 0.25rem" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">
<input type="text" class="form-control" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">
</div>
`))
@ -89,6 +91,7 @@ const BOOTSTRAP_ICONS = ['alarm', 'archive', 'arrow-left', 'arrow-right', 'bag',
* 基于 Bootstrap Icons 的可视化选择控件
*/
Component.register('IconPicker', container => {
container._thisObj = container;
container.state = NewState({ value: '', search: '', open: false })
container.addEventListener('bind', e => {
container.state.value = e.detail || ''

View File

@ -1,24 +1,81 @@
import { Component, NewState, Util, $ } from '@apigo.cc/state'
import { Component, NewState, Util, $, State } from '@apigo.cc/state'
import { HTTP } from './http.js'
/**
* AutoForm 蓝图定义
*/
const AUTOFORM_BLUEPRINT = Util.makeDom(/*html*/`
<div class="auto-form-root">
<form $class="\${this.inline ? 'd-flex flex-wrap align-items-center gap-3' : (this.vertical ? 'd-flex flex-column' : (this.horizontal ? 'auto-grid-form forced-horizontal' : 'auto-grid-form'))}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
<template $each="this.state.schema || []">
<div style="display:contents" $if="this.checkIf(item)">
<label $name="item.name" $class="\${this.inline ? 'mb-0 text-muted text-nowrap' : 'col-form-label text-muted'}" $text="item.label"></label>
<div control-wrapper $class="\${this.inline ? 'd-flex align-items-center' : 'mb-3 d-flex align-items-center'}">
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="form-control \${item.type === 'number' ? 'text-end' : ''}">
<select $if="item.type === 'select'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select">
<option value="" $if="item.placeholder" $text="item.placeholder" disabled selected></option>
<option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option>
</select>
<div $if="['checkbox', 'radio'].includes(item.type)" class="d-flex align-items-center flex-wrap gap-3 h-100">
<label $each="item.options || [item.text||item.label||item.name]" as="opt" class="form-check mb-0 d-flex align-items-center" style="padding-left:0; cursor:pointer;">
<input $name="item.name" class="form-check-input m-0 me-2" style="float:none;" $type="item.type" $.="item.setting || {}" $value="item.options?opt:'on'" $bind="this.data[item.name]">
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="opt.label || opt" class="form-check-label"></span>
</label>
</div>
<div $if="item.type === 'switch'" class="form-check form-switch fs-5 d-flex align-items-center m-0" style="padding-left:0; min-height:0; display:flex !important;">
<input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" 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>
</template>
<div $if="!this.nobutton" $class="\${this.inline ? '' : 'd-flex justify-content-end align-items-baseline gap-3 mt-2'}" $style="\${this.inline ? '' : 'grid-column:1/-1'}">
<div slot-id="actions"></div>
<button type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
</div>
</form>
</div>
`)
const AUTOFORM_STYLE = Util.makeDom(/*html*/`<style>
.auto-grid-form { display: block; }
.auto-grid-form .col-form-label { text-align: left; margin-bottom: 0.25rem; padding-top: 0; }
@media (min-width: 576px) {
.auto-grid-form { display: grid; grid-template-columns: max-content 1fr; gap: 0 1.5rem; }
.auto-grid-form .col-form-label { text-align: right; margin-bottom: 1rem; padding-right: 0; max-width: 200px; padding-top: calc(0.375rem + 1px); }
.auto-grid-form [control-wrapper] { min-height: calc(2.25rem + 2px); }
}
.auto-grid-form.forced-horizontal { display: grid !important; grid-template-columns: max-content 1fr !important; gap: 0 1.5rem !important; }
.auto-grid-form.forced-horizontal .col-form-label { text-align: right !important; margin-bottom: 1rem !important; padding-right: 0 !important; max-width: 200px !important; padding-top: calc(0.375rem + 1px) !important; }
.auto-grid-form.forced-horizontal [control-wrapper] { min-height: calc(2.25rem + 2px) !important; }
.auto-form-root .form-check-input { width: 1.2em; height: 1.2em; }
.auto-form-root .form-switch .form-check-input { width: 2em; }
</style>`)
Component.register('AutoForm', container => {
if (!container.state.schema) container.state.schema = []
const ensureProxy = v => (v && typeof v === 'object' && !v.__isProxy) ? NewState(v) : v;
container.state.__watch('data', v => container.data = ensureProxy(v));
container.data = ensureProxy(container.state.data || {});
container.vertical = container.hasAttribute('vertical')
container.horizontal = container.hasAttribute('horizontal')
container.inline = container.hasAttribute('inline')
container.nobutton = container.hasAttribute('nobutton')
container.nobutton = container.hasAttribute('nobutton')
container.request = { method: 'POST' }
container.response = {}
container.result = null
// Ensure data is reactive and mapped to state.data for template context
Object.defineProperty(container, 'data', {
get: () => container.state.data,
set: v => { container.state.data = (v?.__isProxy ? v : NewState(v || {})); }
})
container.data = container.data || {} // Initialize
container._thisObj = container
// TODO: $$ 双重计算机制暂不提供循环依赖保护schema 编写者需确保 item.if 逻辑无副作用
container.form = $(container, 'form')
container.submit = (opt = {}) => {
if (!container.form.reportValidity()) return globalThis.UI?.toast?.('{#verify failed#}', { type: 'danger' })
@ -38,126 +95,84 @@ Component.register('AutoForm', container => {
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
})
}
}, Util.makeDom(/*html*/`
<div $class="auto-form-root d-flex \${this.inline ? 'auto-form-inline h-100 w-100' : ''}">
<form $class="w-100 \${this.inline ? 'd-flex align-items-stretch flex-fill h-100' : (this.vertical ? 'd-flex flex-column flex-fill' : 'auto-grid-form')}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
<template $each="this.state.schema || []" as="item">
<div $$if="item.if || 'true'" style="display:contents">
<label $if="!this.inline" $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
<div control-wrapper $class="\${this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'}">
<input $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)" $name="item.name" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="form-control h-100 \${item.type === 'number' ? 'text-end' : ''}">
<select $if="item.type === 'select'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select h-100">
<option value="" $if="item.placeholder" $text="item.placeholder" disabled></option>
<option $each="item.options" $value="item.value || item" $text="item.label || item"></option>
</select>
<div $if="['checkbox', 'radio'].includes(item.type)" $class="\${this.inline ? 'h-100 d-flex align-items-center px-2 gap-3 justify-content-center w-100 bg-body' : ''}" style="white-space:nowrap; border-radius: 4px;">
<label $each="item.options || [item.text||item.label||item.name]" as="option" $class="form-check \${item.vertical ? '' : 'form-check-inline'} mb-0 d-flex align-items-center p-0">
<input $name="item.name" class="form-check-input me-1" $type="item.type" $.="item.setting || {}" $value="item.options?option:'on'" $bind="this.data[item.name]">
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="option" class="form-check-label"></span>
</label>
</div>
<div $if="item.type === 'switch'" $class="form-check form-switch fs-4 h-100 d-flex align-items-center px-2 m-0 \${this.inline ? 'justify-content-center w-100' : ''}" style="padding-left:0">
<input $name="item.name" class="form-check-input m-0" type="checkbox" style="cursor:pointer" $bind="this.data[item.name]" $onchange="event.stopPropagation()">
</div>
<textarea $if="item.type === 'textarea'" $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea>
</div>
</div>
</template>
<div $if="!this.inline && !this.nobutton" 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} }
.auto-form-inline { background-color: var(--bs-body-bg); }
.auto-form-inline.min-h-100 { min-height: 100%; height: auto; }
.auto-form-inline .form-control, .auto-form-inline .form-select { border-radius: 0; border: none; background-color: transparent !important; min-height: 100%; height: auto; width: 100%; padding: 0 8px; box-shadow: none; color: inherit; }
.auto-form-inline .form-select { background-position: right 4px center; background-size: 12px 10px; padding-right: 20px; }
.auto-form-inline textarea.form-control { padding: 8px; min-height: 100px; height: auto; }
</style>`))
const _pendingAutoFormComponents = []
container.checkIf = (item) => {
if (!item.if) return true
try {
const fn = new Function('Hash', 'LocalStorage', 'State', 'item', 'data', 'return ' + item.if)
return fn.call(container, globalThis.Hash, globalThis.LocalStorage, globalThis.State, item, container.data)
} catch (e) {
return false
}
}
}, AUTOFORM_BLUEPRINT, AUTOFORM_STYLE)
const findAnchorInBlueprint = (root) => {
let f = root.querySelector('[control-wrapper]');
if (f) return f;
for (const t of root.querySelectorAll('template')) {
f = findAnchorInBlueprint(t.content); if (f) return f;
}
return null;
}
export const AutoForm = {
customTypes: [],
register: (name, typeName) => {
const entry = { name, typeName: typeName || name }
if (typeof document !== 'undefined') {
if (document.readyState !== 'loading' && Component.getTemplate('AutoForm')) AutoForm._addAutoFormComponent(entry)
else _pendingAutoFormComponents.push(entry)
const type = typeName || name
if (!AutoForm.customTypes.find(t => t.name === name)) {
AutoForm.customTypes.push({ name, typeName: type })
AutoForm._addAutoFormComponent(name, type)
}
},
_addAutoFormComponent: entry => {
const { name, typeName } = entry
const template = Component.getTemplate('AutoForm')
if (template) {
let container = $(template.content, '[control-wrapper]')
if (!container) {
const nested = $(template.content, 'template')
if (nested) container = $(nested.content, '[control-wrapper]')
}
if (container && !container.querySelector(name)) {
container.appendChild(Util.makeDom(`<${name} $if="item.type?.toLowerCase() === '${typeName.toLowerCase()}'" $name="item.name" $.="item.setting || {}" $bind="thisNode.closest('AutoForm').data[item.name]"></${name}>`))
}
}
}
}
if (typeof document !== 'undefined') {
const initAutoForm = () => {
_pendingAutoFormComponents.forEach(entry => AutoForm._addAutoFormComponent(entry))
_pendingAutoFormComponents.length = 0
}
if (document.readyState !== 'loading') setTimeout(initAutoForm, 100)
else document.addEventListener('DOMContentLoaded', () => setTimeout(initAutoForm, 100), true)
_addAutoFormComponent: (name, type) => {
const wrapper = findAnchorInBlueprint(AUTOFORM_BLUEPRINT)
if (wrapper) {
const node = Util.makeDom(`<${name} $if="item.type?.toLowerCase() === '${type.toLowerCase()}'" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="w-100"></${name}>`)
wrapper.appendChild(node)
}
}
}
/**
* TagsInput
* 100% 还原 base_original.js 逻辑与写法
*/
Component.register('TagsInput', container => {
container.state = NewState({ tags: [] })
container._thisObj = container; // 夺回上下文主权
container.addEventListener('bind', e => {
container.state.tags = Array.isArray(e.detail) ? e.detail : []
})
// Handle direct property access
Object.defineProperty(container, 'value', {
get: () => container.state.tags,
set: v => { container.state.tags = Array.isArray(v) ? v : []; }
})
}, Util.makeDom(/*html*/`
<div class="form-control d-flex flex-wrap gap-1 align-items-center w-100" style="cursor:text; min-height: 38px; border: none !important; background: transparent !important; box-shadow: none !important; padding: 0 8px !important;">
<button $each="(this.state && this.state.tags) || []" type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${Util.getFunctionBody(function (event) {
if (['Backspace', 'Delete'].includes(event.key)) {
event.preventDefault()
if (this.state?.tags) {
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text">
<template $each="this.state.tags">
<button type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown='${Util.getFunctionBody(function (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: true, detail: this.state.tags }))
this.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: this.state.tags }))
Promise.resolve().then(() => {
const buttons = this.querySelectorAll('button');
if (buttons.length > 0) (buttons[index > 0 ? index - 1 : 0] || buttons[0]).focus()
else { const input = $(this, 'input'); if (input) input.focus(); }
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; height: 32px" $onkeydown="${Util.getFunctionBody(function (event) {
if (event.isComposing) return
if (['Enter', ',', ' '].includes(event.key)) {
event.preventDefault()
const v = thisNode.value.trim()
if (v && this.state?.tags && !this.state.tags.includes(v)) {
this.state.tags.push(v)
this.state.tags = this.state.tags
this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: this.state.tags }))
})}' $text="item"></button>
</template>
<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(function (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 = ''
}
thisNode.value = ''
}
})}">
})}'>
</div>
`), Util.makeDom(/*html*/`<style>
TagsInput { display: flex; align-items: center; min-height: 100%; width: 100%; background: transparent !important; }
TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}
</style>`))
`), Util.makeDom(/*html*/`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>`))
AutoForm.register('TagsInput')

View File

@ -29,6 +29,8 @@ import { UI } from './ui.js'
import { AutoForm } from './form.js'
import { MouseMover } from './interaction.js'
import { VirtualScroll } from './list.js'
import './nav.js'
import './controls.js'
globalThis.HTTP = HTTP
globalThis.UI = UI
@ -37,15 +39,11 @@ globalThis.MouseMover = MouseMover
globalThis.VirtualScroll = VirtualScroll
const ApigoBase = {
HTTP, UI, AutoForm, MouseMover, VirtualScroll, State
HTTP, UI, AutoForm, MouseMover, VirtualScroll, State,
List: VirtualScroll
};
import { RefreshState } from '@apigo.cc/state'
if (typeof document !== 'undefined') {
globalThis.ApigoBase = ApigoBase;
const doRefresh = () => RefreshState(document.documentElement)
if (document.readyState !== 'loading') setTimeout(doRefresh, 1)
else document.addEventListener('DOMContentLoaded', () => setTimeout(doRefresh, 1), true)
}

View File

@ -16,9 +16,6 @@ export const VirtualScroll = (options = {}) => {
const size = list.length; groupItemCount = Math.ceil(Math.sqrt(size)) || 10;
const style = window.getComputedStyle(container);
padTop = parseFloat(style.paddingTop) || 0; rowGap = parseFloat(style.rowGap) || 0;
// Optimization: Give a reasonably large initial buffer instead of just 30 to prevent the
// "first scroll unresponsiveness" where the user scrolls past the small initial slice
// before the init() callback has time to map all heights.
const visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (providedItemHeight || 32)));
return list.slice(0, Math.min(visibleCount * 3, size));
},
@ -26,19 +23,13 @@ export const VirtualScroll = (options = {}) => {
if (listInited) return;
const size = list.length;
let defaultHeight = providedItemHeight || avg.get() || 32;
// Optimization: If the first item declares a fixed height, use it as the global baseline.
// This completely bypasses the need for DOM measurement (node.offsetHeight) in fixed-height scenarios.
if (size > 0 && typeof list[0] === 'object' && list[0] !== null && list[0]._itemHeight) {
defaultHeight = list[0]._itemHeight;
}
avg.add(defaultHeight);
if (itemMarginTop === null) { itemMarginTop = 0; itemMarginBottom = 0; }
for (let i = 0; i < size; i++) {
if (!itemHeights.has(i)) {
// Fallback to individual item height if specified, otherwise global default
const ih = (typeof list[i] === 'object' && list[i] !== null && list[i]._itemHeight) ? list[i]._itemHeight : defaultHeight;
itemHeights.set(i, ih);
}
@ -72,14 +63,11 @@ export const VirtualScroll = (options = {}) => {
let loopCount = 0;
for (let i = 0; i < size; i++) {
if (++loopCount > size * 2) {
throw new Error(`VirtualScroll.calc infinite loop detected at i=${i}, status=${status}, size=${size}, groupItemCount=${groupItemCount}`);
}
if (++loopCount > size * 2) throw new Error('VirtualScroll infinite loop');
if (status === 0) {
const gh = groupHeights.get(i);
if (gh && prev + gh <= scrollTop && (i + groupItemCount < size)) {
prev += gh;
i += groupItemCount - 1;
prev += gh; i += groupItemCount - 1;
} else {
const ih = itemHeights.get(i);
if (prev + ih <= scrollTop && i < size - 1) {
@ -96,19 +84,11 @@ export const VirtualScroll = (options = {}) => {
}
} else if (status === 1) {
const gh = groupHeights.get(i);
if (gh) {
post += gh;
i += groupItemCount - 1;
} else {
post += itemHeights.get(i);
}
if (gh) { post += gh; i += groupItemCount - 1; }
else post += itemHeights.get(i);
}
}
// 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 };
}
@ -129,7 +109,8 @@ Component.register('List', container => {
parentfield: 'parent', groupicon: 'folder', itemicon: 'file'
}
container.collapsed = NewState({})
container.state.renderedList = []
const updateFlatList = () => {
Util.updateDefaults(container, defaultSets)
const list = container.state.list || [], flatList = []
@ -152,52 +133,44 @@ Component.register('List', container => {
})
traverse(childrenMap[''] || [], 0, [])
} else list.forEach(item => flatList.push({ type: 'item', ...item }))
container.state._flatList = flatList
container.state.flatList = flatList
}
container.state.__watch('list', updateFlatList)
const vs = container.fast ? VirtualScroll() : null
container.state._renderedList = []
let refreshing = false
container.refresh = () => {
if (!container.fast || refreshing) return
refreshing = true
try {
const res = vs.calc(container, container.state._flatList)
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`
if (container.state._listStartIndex !== res.listStartIndex) {
container.state._listStartIndex = res.listStartIndex
}
const cur = container.state._renderedList || []
if (cur.length !== res.renderedList.length || cur[0] !== res.renderedList[0] || cur[cur.length - 1] !== res.renderedList[res.renderedList.length - 1]) {
container.state._renderedList = res.renderedList
}
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.onItemUpdate = (index, node) => { if (container.fast) vs.update(index + (container.state.listStartIndex || 0), node) }
container.state.__watch('_flatList', flatList => {
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.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('itemclick', { bubbles: false, detail: { item, index: index + (container.fast ? (container.state._listStartIndex || 0) : 0) } }))
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]
@ -209,22 +182,32 @@ Component.register('List', container => {
}, 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 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)">
<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'">
<span $if="this.groupicon" $class="bi bi-\${this.groupicon} text-body"></span>
<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-truncate" style="width: 0" $text="\${item[this.groupsummaryfield]}"></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'">
<div $if="this.mode === 'tree'" $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)">
<i $if="this.collapsible && item._hasChildren" $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
</div>
<span $if="this.mode === 'tree'" $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
<span $if="this.mode !== 'tree' && this.itemicon" class="bi bi-\${this.itemicon} text-body"></span>
<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 small flex-fill text-end text-truncate" style="width: 0" $text="\${item[this.summaryfield]}"></div>
<div class="text-muted small flex-fill text-end" $text="\${item[this.summaryfield]}"></div>
<div slot-id="item-actions"></div>
</template>
</div>

View File

@ -8,31 +8,46 @@ Component.register('Nav', container => {
}
}, Util.makeDom(/*html*/`
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-3 \${this.vertical ? 'py-3' : 'pb-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}">
<img $if="this.state.brand.image" $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
<i $if="this.state.brand.icon" $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
<span $if="this.state.brand.label" $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
<template $if="this.state?.brand?.image">
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
</template>
<template $if="this.state?.brand?.icon">
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
</template>
<template $if="this.state?.brand?.label">
<span $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
</template>
<div $class="\${this.vertical ? 'w-100' : 'ms-2'}"></div>
<div $each="this.state.list" $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
<button $if="item.type==='button'" $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state.list.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
</button>
<div $if="item.type==='dropdown'" $class="dropdown \${this.vertical ? 'w-100' : ''}">
<button $class="nav-link \${Hash.nav===item.name?'active':''} w-100 \${this.vertical ? 'text-start d-flex justify-content-between align-items-center' : ''}" data-bs-toggle="dropdown">
<span><i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state.list.length>5?'lg':'md') + '-inline'}" $text="item.label"></span></span>
<i $if="this.vertical" class="bi bi-chevron-right small"></i>
</button>
<div $class="dropdown-menu \${this.vertical ? 'position-static border-0 bg-transparent shadow-none ps-3' : 'dropdown-menu-end p-3 bg-body-secondary shadow'}" $style="width: \${this.vertical ? '100%' : (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>
<template $each="this.state?.list || []">
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
<template $if="item.type==='button'">
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
</button>
</template>
<template $if="item.type==='dropdown'">
<div class="dropdown">
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" data-bs-toggle="dropdown">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.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 $class="dropdown-menu \${this.vertical?'position-static':'dropdown-menu-end'} p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
<template $each="item.list" as="subitem">
<template $if="subitem.type==='button'">
<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>
</template>
<template $if="subitem.type==='switch'">
<div 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>
</template>
</div>
</template>
</div>
</div>
</template>
</div>
</div>
</template>
</div>
`))

View File

@ -31,10 +31,14 @@ Component.register('Dialog', Component.getSetupFunction('Modal'), Util.makeDom(/
<div class="modal fade" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div $class="modal-content border-\${this.state?.type || 'primary'} border-2 shadow-lg">
<div $if="this.state?.title" $class="modal-header py-2 px-3 bg-light fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></div>
<template $if="this.state?.title">
<div $class="modal-header py-2 px-3 bg-light fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></div>
</template>
<div slot-id="body" class="modal-body p-4"><div $html="this.state?.message"></div></div>
<div class="modal-footer py-2 px-3 bg-light">
<button $each="this.state?.buttons || ['{#Close#}']" type="button" $class="btn btn-sm px-3 btn-\${index === (this.state?.buttons || []).length - 1 ? (this.state?.type && this.state?.type !== 'body' ? this.state?.type : 'primary') : 'outline-secondary border'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
<template $each="this.state?.buttons || ['{#Close#}']">
<button type="button" $class="btn btn-sm px-3 btn-\${index === (this.state?.buttons || []).length - 1 ? (this.state?.type && this.state?.type !== 'body' ? this.state?.type : 'primary') : 'outline-secondary border'}" $onclick="this.result=index+1;this.hide()" $text="\${item}"></button>
</template>
</div>
</div>
</div>
@ -88,12 +92,16 @@ Component.register('Toast', container => {
<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>
<template $if="this.state?.left !== undefined">
<span class="small text-dim ms-2" $text="\${this.state?.left}s"></span>
</template>
</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>
<template $each="this.state?.buttons || ['{#Close#}']">
<button type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button>
</template>
</div>
</div>
</div>

32
test/atomic_check.spec.js Normal file
View File

@ -0,0 +1,32 @@
import { test, expect } from '@playwright/test';
test('Capability demo page atomic tests verification', async ({ page }) => {
page.on('console', msg => console.log('BROWSER:', msg.text()));
await page.goto('http://localhost:5173/test/capability.html');
await page.waitForTimeout(3000);
const testResults = await page.evaluate(() => {
const getTexts = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.textContent.trim());
const getColors = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.style.color);
return {
textBinding: getTexts('p[$text="DemoState.testTitle"]'),
textColor: getColors('p[$text="DemoState.testTitle"]'),
ifTrue: document.querySelector('.alert-success')?.textContent.trim(),
ifFalse: document.querySelector('.alert-danger')?.textContent.trim(),
eachItems: getTexts('li.list-group-item'),
nestedEachInIf: getTexts('.badge.bg-primary'),
nestedIfInEach: getTexts('.bg-white')
};
});
console.log('Atomic Test Results:', JSON.stringify(testResults, null, 2));
expect(testResults.textBinding).toContain('Framework Is Active');
expect(testResults.ifTrue).toBe('显示的内容 (True)');
expect(testResults.ifFalse).toBeUndefined();
expect(testResults.eachItems).toHaveLength(3);
expect(testResults.nestedEachInIf).toHaveLength(3);
expect(testResults.nestedIfInEach).toHaveLength(2); // Item A and C are visible
});

297
test/capability.html Normal file
View File

@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Apigo Base 能力展示 (Mega Demo - Sync)</title>
<!-- 引入 Bootstrap 5.3 CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<!-- 核心同步脚本加载 (使用本地副本消灭路径风险) -->
<script src="./lib/state.js"></script>
<script src="./lib/bootstrap.js"></script>
<script src="./lib/base.js"></script>
<style>
body { height: 100vh; overflow: hidden; }
.main-container { height: 100%; display: flex; }
.sidebar { min-width: 200px; max-width: 500px; display: flex; flex-direction: column; }
.content { flex: 1; overflow: auto; padding: 20px; }
.section-card { margin-bottom: 30px; border: 1px solid #dee2e6; border-radius: 8px; overflow: hidden; }
.section-header { background: #f8f9fa; padding: 10px 15px; border-bottom: 1px solid #dee2e6; font-weight: bold; }
.section-body { padding: 15px; }
[data-bs-theme="dark"] .section-header { background: #343a40; border-bottom-color: #495057; }
[data-bs-theme="dark"] .section-card { border-color: #495057; }
.btn-xs { padding: 0.1rem 0.3rem; font-size: 0.75rem; }
</style>
<script>
// 1. 静态结构数据 (用于组件初始化,必须在 body 解析前就绪)
window.brand = { icon: 'cpu', label: 'Base 能力展示' };
window.navList = [
{ type: 'button', name: 'welcome', label: '项目概览', icon: 'house' },
{ type: 'button', name: 'forms', label: '表单与控件', icon: 'input-cursor-text' },
{ type: 'button', name: 'lists', label: '增强列表', icon: 'list-ul' },
{ type: 'button', name: 'ui', label: '交互反馈', icon: 'chat-square-dots' },
{ type: 'button', name: 'api', label: '网络通讯', icon: 'cloud-arrow-down' }
];
window.formSchema = [
{ name: 'username', label: '用户名', type: 'text', placeholder: '请输入用户名', setting: { required: true } },
{ name: 'password', label: '密码', type: 'password' },
{ name: 'dateRange', label: '日期范围', type: 'DatePicker', setting: { rangeEnd: 'endDate' } },
{ name: 'endDate', label: '结束日期', type: 'date', hidden: true },
{ name: 'theme', label: '主题色', type: 'ColorPicker' },
{ name: 'icon', label: '图标', type: 'IconPicker' },
{ name: 'tags', label: '标签', type: 'TagsInput' },
{ name: 'role', label: '角色', type: 'select', options: [{ label: '管理员', value: 'admin' }, { label: '普通用户', value: 'user' }] },
{ name: 'gender', label: '性别', type: 'checkbox', options: ['男', '女', '保密'] },
{ name: 'notify', label: '接收通知', type: 'switch' }
];
// 2. 初始化全局 State 初始值
// 使用 window.State 因为 state.js 已经同步加载完毕
Object.assign(window.State, {
currentSection: 'welcome',
testTitle: 'Synchronous State Ready',
testShow: true,
testColor: 'blue',
testItems: ['Alpha', 'Beta', 'Gamma'],
showOuter: true,
innerItems: ['Red', 'Green', 'Blue'],
members: [
{ id: 1, name: 'Alice (1)', odd: true },
{ id: 2, name: 'Bob (2)', odd: false },
{ id: 3, name: 'Charlie (3)', odd: true },
{ id: 4, name: 'David (4)', odd: false },
{ id: 5, name: 'Eve (5)', odd: true }
],
showOddOnly: false,
listData: [],
listGroups: [
{ id: 'group1', label: '开发团队', icon: 'code-slash' },
{ id: 'group2', label: '设计团队', icon: 'palette' }
],
listMode: 'tree',
formData: { username: 'ApigoUser', theme: '#0d6efd', tags: ['Fast'] },
apiResult: null
});
if (!Hash.nav) Hash.nav = 'welcome';
</script>
</head>
<body $data-bs-theme="LocalStorage.darkMode ? 'dark' : 'light'">
<div class="main-container">
<!-- 侧边栏导航 -->
<div id="sidebar" class="sidebar bg-body-tertiary border-end" style="width: 260px;">
<Nav id="mainNav" vertical class="flex-fill"
$.state.brand="window.brand"
$.state.list="window.navList"
$onnav="State.currentSection = event.detail.item.name">
</Nav>
</div>
<!-- 拖拽调节器 -->
<Resizer target="sidebar" min="150" max="450"></Resizer>
<!-- 主内容区 -->
<div class="content bg-body">
<div class="container-fluid">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 $text="window.navList.find(s => s.name === State.currentSection)?.label || '欢迎'"></h2>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" onclick="LocalStorage.darkMode = !LocalStorage.darkMode">
<i class="bi" $class="LocalStorage.darkMode ? 'bi-sun' : 'bi-moon'"></i>
切换主题
</button>
</div>
</div>
<!-- 1. 表单展示 -->
<template $if="State.currentSection === 'forms'">
<div class="section-card">
<div class="section-header">AutoForm & 扩展控件</div>
<div class="section-body">
<div class="row">
<div class="col-md-8">
<AutoForm id="demoForm" vertical
$.state.schema="window.formSchema"
$.state.data="State.formData"
$onsubmit="UI.toast('提交数据: ' + JSON.stringify(event.detail))">
</AutoForm>
</div>
<div class="col-md-4">
<h6>实时数据预览:</h6>
<pre class="bg-light p-2 border rounded" $text="JSON.stringify(State.formData, null, 2)"></pre>
<button class="btn btn-primary w-100" onclick="resetFormData()">重置数据</button>
</div>
</div>
</div>
</div>
</template>
<!-- 2. 列表展示 -->
<template $if="State.currentSection === 'lists'">
<div class="section-card">
<div class="section-header">List 增强列表 (虚拟滚动 + 树形)</div>
<div class="section-body">
<div class="mb-3 d-flex gap-2">
<button class="btn btn-sm btn-outline-primary" onclick="State.listMode = 'normal'">普通列表</button>
<button class="btn btn-sm btn-outline-primary" onclick="State.listMode = 'group'">分组列表</button>
<button class="btn btn-sm btn-outline-primary" onclick="State.listMode = 'tree'">树形列表</button>
</div>
<div style="height: 500px;" class="border rounded overflow-hidden">
<List id="demoList" fast collapsible auto-select class="h-100 overflow-auto"
$mode="State.listMode"
$.state.list="State.listData"
$.state.groups="State.listGroups"
$onitemclick="UI.toast('点击项目: ' + event.detail.item.label)">
<template slot-id="item">
<i class="bi me-2" $class="item.icon || 'bi-file-earmark'"></i>
<span $text="item.label"></span>
<span class="ms-auto badge bg-secondary" $text="item.id"></span>
</template>
</List>
</div>
</div>
</div>
</template>
<!-- 3. 交互展示 -->
<template $if="State.currentSection === 'ui'">
<div class="section-card">
<div class="section-header">UI 交互工具</div>
<div class="section-body">
<div class="row g-3">
<div class="col-md-4"><button class="btn btn-info w-100" onclick="UI.toast('这是一个通知消息')">Toast (Info)</button></div>
<div class="col-md-4"><button class="btn btn-danger w-100" onclick="UI.toast('操作失败!', { type: 'danger' })">Toast (Danger)</button></div>
<div class="col-md-4"><button class="btn btn-success w-100" onclick="UI.toastConfirm('你确定要完成吗?').then(r => UI.toast(r ? '已确认' : '已取消'))">Toast Confirm</button></div>
<div class="col-md-4"><button class="btn btn-primary w-100" onclick="UI.alert('这是一个警告框')">UI.alert</button></div>
<div class="col-md-4"><button class="btn btn-warning w-100" onclick="UI.confirm('你确定要删除吗?').then(r => UI.toast(r ? '已点击确认' : '已点击取消'))">UI.confirm</button></div>
<div class="col-md-4"><button class="btn btn-dark w-100" onclick="showComplexDialog()">UI.showDialog</button></div>
<div class="col-md-4"><button class="btn btn-secondary w-100" onclick="testHTTP()">HTTP.get Test</button></div>
</div>
</div>
</div>
</template>
<!-- 4. 网络展示 -->
<template $if="State.currentSection === 'api'">
<div class="section-card">
<div class="section-header">API 声明式请求</div>
<div class="section-body">
<API id="userApi"
$request="{ url: 'https://jsonplaceholder.typicode.com/users/1', method: 'GET' }"
$onresponse="State.apiResult = event.detail.result"
$onerror="UI.toast('API 请求失败: ' + event.detail.error, { type: 'danger' })">
</API>
<div class="d-flex gap-2 mb-3">
<button class="btn btn-primary" onclick="userApi.do()">触发请求</button>
<button class="btn btn-outline-secondary" onclick="State.apiResult = null">清空结果</button>
</div>
<h6>请求结果 (JSONPlaceholder):</h6>
<pre class="bg-light p-2 border rounded" $text="State.apiResult ? JSON.stringify(State.apiResult, null, 2) : '等待请求...'"></pre>
</div>
</div>
</template>
<!-- 默认页 -->
<template $if="State.currentSection === 'welcome'">
<div class="text-center py-5">
<i class="bi bi-rocket-takeoff text-primary" style="font-size: 5rem;"></i>
<h1 class="mt-3">Apigo Base Mega Demo</h1>
<p class="text-muted">点击左侧菜单查看不同组件的能力展示</p>
</div>
<div class="mt-5 p-4 border rounded bg-light">
<h4 class="mb-4 border-bottom pb-2 text-start">核心原子能力验证 (State singleton)</h4>
<div class="row g-4 text-start">
<!-- 1. $text & style binding -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">1. $text & 样式绑定</div>
<div class="card-body text-center">
<p class="fs-4 fw-bold" $text="State.testTitle" $style="'color:' + State.testColor"></p>
<button class="btn btn-sm btn-outline-primary" onclick="State.testColor = State.testColor === 'blue' ? 'green' : 'blue'">切换颜色</button>
</div>
</div>
</div>
<!-- 2. $if (True/False) -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">2. $if 显式模板判断</div>
<div class="card-body text-center">
<template $if="State.testShow">
<div class="alert alert-success py-2">已激活 ($if="true")</div>
</template>
<button class="btn btn-sm btn-outline-primary mt-2" onclick="State.testShow = !State.testShow">切换显示状态</button>
</div>
</div>
</div>
<!-- 3. $each (Simple) -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">3. $each 循环渲染</div>
<div class="card-body">
<div class="list-group list-group-flush border rounded">
<template $each="State.testItems">
<div class="list-group-item py-1" $text="item"></div>
</template>
</div>
</div>
</div>
</div>
<!-- 4. Nested each inside if -->
<div class="col-md-6">
<div class="card h-100">
<div class="card-header">4. $if 嵌套 $each</div>
<div class="card-body text-center">
<template $if="State.showOuter">
<div class="p-2 border rounded bg-white mb-2">
<template $each="State.innerItems">
<span class="badge bg-secondary me-1" $text="item"></span>
</template>
</div>
</template>
<button class="btn btn-sm btn-outline-primary" onclick="State.showOuter = !State.showOuter">切换外层容器</button>
</div>
</div>
</div>
<!-- 5. Nested if inside each (Filter) -->
<div class="col-md-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
5. $each 嵌套 $if (奇偶成员过滤)
<button class="btn btn-xs btn-primary py-0" onclick="State.showOddOnly = !State.showOddOnly" $text="State.showOddOnly ? '显示全部' : '仅看奇数项'"></button>
</div>
<div class="card-body">
<div class="d-flex flex-wrap gap-2">
<template $each="State.members">
<template $if="!State.showOddOnly || item.odd">
<div class="p-2 border rounded bg-white shadow-sm">
<i class="bi bi-person-fill text-primary"></i>
<span $text="item.name"></span>
</div>
</template>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
</div>
</div>
</div>
<script src="./capability.js"></script>
</body>
</html>

49
test/capability.js Normal file
View File

@ -0,0 +1,49 @@
// 1. 生成演示大列表数据 (异步填充)
const generateListData = () => {
const data = [];
data.push({ id: 'r1', label: '研发中心', icon: 'building', parent: '', group: 'group1' });
data.push({ id: 'r2', label: '创意中心', icon: 'brush', parent: '', group: 'group2' });
for (let i = 1; i <= 3; i++) {
data.push({ id: `dev-${i}`, label: `开发 ${i}`, parent: 'r1', group: 'group1' });
data.push({ id: `design-${i}`, label: `设计 ${i}`, parent: 'r2', group: 'group2' });
}
for (let i = 1; i <= 100; i++) {
data.push({ id: `user-${i}`, label: `成员 ${i}`, parent: i <= 50 ? 'dev-1' : 'design-1', icon: 'person' });
}
State.listData = data;
};
generateListData();
// 2. 业务逻辑
window.resetFormData = () => {
Object.assign(State.formData, {
username: 'ApigoUser',
dateRange: '2026-06-01',
endDate: '2026-06-07',
theme: '#0d6efd',
icon: 'lightning-charge',
tags: ['Fast', 'Powerful', 'Easy'],
role: 'admin',
gender: ['男'],
notify: true
});
};
window.showComplexDialog = async () => {
const result = await UI.showDialog({
title: '系统升级确认',
message: '<p>检测到有新版本可用,是否立即升级?</p><div class="alert alert-warning">升级期间服务可能会短暂中断。</div>',
buttons: ['以后再说', '详细了解', '立即升级'],
type: 'warning'
});
if (result === 3) UI.toast('开始升级...', { type: 'success' });
else if (result === 2) UI.alert('当前版本: v1.0.11\n目标版本: v1.1.0\n更新内容: 修复了若干已知 Bug。');
};
window.testHTTP = async () => {
UI.toast('正在发起请求...');
const resp = await HTTP.get({ url: 'https://jsonplaceholder.typicode.com/todos/1' });
if (resp.ok) UI.alert('HTTP 请求成功:\n' + JSON.stringify(resp.result, null, 2));
else UI.toast('请求失败: ' + resp.error, { type: 'danger' });
};

29
test/deep_dump.spec.js Normal file
View File

@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
test('Capability demo page deep DOM dump', async ({ page }) => {
page.on('console', msg => console.log('BROWSER:', msg.text()));
await page.goto('http://localhost:5173/test/capability.html');
await page.waitForTimeout(3000);
const dump = await page.evaluate(() => {
const nav = document.getElementById('mainNav');
const form = document.getElementById('demoForm');
return {
nav: {
tagName: nav?.tagName,
childCount: nav?.children.length,
innerHTML: nav?.innerHTML
},
form: {
tagName: form?.tagName,
childCount: form?.children.length,
innerHTML: form?.innerHTML
}
};
});
console.log('Deep DOM Dump:', JSON.stringify(dump, null, 2));
expect(dump.nav.childCount).toBeGreaterThan(0);
});

108
test/form_test.html Normal file
View File

@ -0,0 +1,108 @@
<!DOCTYPE html>
<html lang="zh-CN" data-bs-theme="dark">
<head>
<meta charset="UTF-8">
<title>AutoForm Mega Unit Test</title>
<script src="./lib/state.js"></script>
<script src="./lib/bootstrap.js"></script>
<script src="./lib/base.js"></script>
<script>
window.fullSchema = [
{ name: 't', label: 'Text Input', type: 'text', placeholder: 'Enter name...' },
{ name: 's', label: 'Single Select', type: 'select', options: [{label:'Option 1', value:'1'}, {label:'Option 2', value:'2'}] },
{ name: 'date', label: 'Single Date', type: 'DatePicker' },
{ name: 'range', label: 'Date Range', type: 'DatePicker', setting: { rangeEnd: 'rangeEnd' } },
{ name: 'c', label: 'Multi Check', type: 'checkbox', options: ['Apple', 'Banana', 'Orange'] },
{ name: 'r', label: 'Single Radio', type: 'radio', options: ['High', 'Medium', 'Low'] },
{ name: 'sw', label: 'Enable Linkage', type: 'switch' },
{ name: 'linkageField', label: 'Secret Key', type: 'password', if: 'this.data.sw', placeholder: 'Only visible when switch is ON' },
{ name: 'cp', label: 'Color Picker', type: 'ColorPicker' },
{ name: 'tg', label: 'Tags System', type: 'TagsInput' },
{ name: 'txt', label: 'Description', type: 'textarea' }
];
window.searchSchema = [
{ name: 'q', type: 'text', placeholder: 'Search...' },
{ name: 'cat', type: 'select', placeholder: 'Category', options: ['All', 'Docs', 'Issues'] }
];
window.compactSchema = [
{ name: 'user', label: 'User', type: 'text' },
{ name: 'role', label: 'Role', type: 'select', options: ['Admin', 'Editor'] }
];
Object.assign(window.State, {
formData: { t: 'Apigo', s: '1', c: ['Apple'], sw: false, cp: '#0d6efd', range: '2026-01-01', rangeEnd: '2026-12-31' },
searchData: { q: '', cat: '' },
compactData: { user: 'Admin', role: 'Admin' }
});
</script>
<style>body { padding: 30px; background: #111; color: #eee; }</style>
</head>
<body>
<div class="container-fluid">
<h2 class="mb-4">AutoForm Mega Test & Function Showcase</h2>
<div class="row g-4">
<!-- 1. 垂直模式 -->
<div class="col-md-4">
<div class="card bg-dark border-secondary h-100">
<div class="card-header border-secondary bg-secondary bg-opacity-10">1. Vertical Mode</div>
<div class="card-body">
<AutoForm id="formV" vertical $.state.schema="window.fullSchema" $.state.data="State.formData"></AutoForm>
</div>
</div>
</div>
<!-- 2. 响应式水平模式 -->
<div class="col-md-8">
<div class="card bg-dark border-secondary">
<div class="card-header border-secondary bg-secondary bg-opacity-10 d-flex justify-content-between">
<span>2. Responsive Horizontal</span>
<small class="text-info">Try shrinking the window!</small>
</div>
<div class="card-body">
<AutoForm id="formH" $.state.schema="window.fullSchema" $.state.data="State.formData">
<template slot="actions">
<button type="button" class="btn btn-outline-info" onclick="UI.toast('Custom Action!')">Custom Button</button>
</template>
</AutoForm>
</div>
</div>
<div class="card bg-dark border-secondary mt-4">
<div class="card-header border-secondary bg-secondary bg-opacity-10">2b. Forced Horizontal</div>
<div class="card-body">
<AutoForm id="formFH" horizontal $.state.schema="window.fullSchema" $.state.data="State.formData"></AutoForm>
</div>
</div>
</div>
</div>
<div class="mt-5">
<h4 class="text-primary border-bottom pb-2">3. Inline Mode Scenarios</h4>
<div class="row align-items-end">
<div class="col-auto">
<h6>Toolbar (No label, with action)</h6>
<div class="p-2 border border-secondary rounded bg-dark d-inline-block shadow-sm">
<AutoForm id="formSearch" inline submitlabel="Search" $.state.schema="window.searchSchema" $.state.data="State.searchData"></AutoForm>
</div>
</div>
<div class="col-auto ms-4">
<h6>Compact Config (With labels, no button)</h6>
<div class="p-2 border border-secondary rounded bg-dark d-inline-block shadow-sm">
<AutoForm id="formCompact" inline nobutton $.state.schema="window.compactSchema" $.state.data="State.compactData"></AutoForm>
</div>
</div>
</div>
</div>
<div class="mt-5 card bg-dark border-primary">
<div class="card-header border-primary text-primary">Live Data Sync Debug</div>
<div class="card-body">
<pre class="m-0 text-success" $text="JSON.stringify(State.formData, null, 2)"></pre>
</div>
</div>
</div>
</body>
</html>

950
test/lib/base.js Normal file

File diff suppressed because one or more lines are too long

7285
test/lib/bootstrap.js vendored Normal file

File diff suppressed because one or more lines are too long

671
test/lib/state.js Normal file
View File

@ -0,0 +1,671 @@
(function(global, factory) {
typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define(["exports"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.ApigoState = {}));
})(this, function(exports2) {
"use strict";
var _a;
let __activeBinding = null;
let __noWriteBack = null;
const _setActiveBinding = (val) => __activeBinding = val;
const _setNoWriteBack = (val) => __noWriteBack = val;
const _notifiers = /* @__PURE__ */ new Set();
const _onNotifyUpdate = (fn) => _notifiers.add(fn);
function NewState(defaults = {}, getter = null, setter = null) {
const _defaults = {};
const _stateMappings = /* @__PURE__ */ new Map();
const _watchers = /* @__PURE__ */ new Map();
const _watchFunc = (k, cb) => {
if (!_watchers.has(k)) _watchers.set(k, /* @__PURE__ */ new Set());
!cb ? _watchers.get(k).clear() : _watchers.get(k).add(cb);
return () => _watchers.get(k).delete(cb);
};
const _unwatchFunc = (k, cb) => {
if (_watchers.has(k)) _watchers.set(k, /* @__PURE__ */ new Set());
_watchers.get(k).delete(cb);
};
const __getter = getter || ((k) => _defaults[k]);
const __setter = setter || ((k, v) => _defaults[k] = v);
Object.assign(_defaults, defaults);
return new Proxy(_defaults, {
get(target, key) {
if (key === "__watch") return _watchFunc;
if (key === "__unwatch") return _unwatchFunc;
if (key === "__isProxy") return true;
if (__activeBinding) {
if (!_stateMappings.has(key)) _stateMappings.set(key, /* @__PURE__ */ new Set());
_stateMappings.get(key).add(__activeBinding);
if (!__activeBinding.node._states) __activeBinding.node._states = /* @__PURE__ */ new Set();
__activeBinding.node._states.add(_stateMappings);
}
return __getter(key);
},
set(target, key, value) {
if (__getter(key) !== value) {
__setter(key, value);
}
if (_watchers.has(key)) {
_watchers.get(key).forEach((cb) => {
const r = cb(value);
if (r !== void 0) {
value = r;
target[key] = value;
}
});
}
if (_watchers.has(null)) {
_watchers.get(null).forEach((cb) => cb(value));
}
if (_stateMappings.has(key)) {
const bindings = _stateMappings.get(key);
for (const binding of bindings) {
if (!binding.node.isConnected) {
bindings.delete(binding);
continue;
}
if (__noWriteBack !== binding.node) {
_notifiers.forEach((fn) => fn(binding));
}
}
}
return true;
}
});
}
const $ = (a, b) => b ? a.querySelector(b) : document.querySelector(a);
const $$ = (a, b) => b ? a.querySelectorAll(b) : document.querySelectorAll(a);
const _components = /* @__PURE__ */ new Map();
const _pendingTemplates = [];
const Component = {
getTemplate: (name) => document.querySelector(`template[component="${name.toUpperCase()}"]`),
register: (name, setupFunc, templateNode = null, ...globalNodes) => {
console.log("Component.register:", name.toUpperCase());
_components.set(name.toUpperCase(), setupFunc);
if (document.readyState !== "loading") Component._addTemplate(name, templateNode, globalNodes);
else _pendingTemplates.push([name, templateNode, globalNodes]);
},
exists: (name) => _components.has(name.toUpperCase()),
getSetupFunction: (name) => _components.get(name.toUpperCase()),
_addTemplate: (name, templateNode, globalNodes) => {
if (templateNode) {
const template = document.createElement("TEMPLATE");
template.setAttribute("component", name.toUpperCase());
template.content.appendChild(templateNode);
document.body.appendChild(template);
}
if (globalNodes) globalNodes.forEach((node) => document.body.appendChild(node));
},
_initPending: () => {
_pendingTemplates.forEach(([name, templateNode, globalNodes]) => Component._addTemplate(name, templateNode, globalNodes));
_pendingTemplates.length = 0;
}
};
function _mergeNode(from, to, scanObj, exists = {}) {
if (from.attributes) {
Array.from(from.attributes).forEach((attr) => {
if (attr.name === "class") return;
if (attr.name === "style") {
if (to.hasAttribute("style")) to.setAttribute("style", `${attr.value}; ${to.getAttribute("style")}`);
else to.setAttribute("style", attr.value);
} else if (!to.hasAttribute(attr.name)) {
to.setAttribute(attr.name, attr.value);
}
});
}
to.classList.add(...from.classList);
const target = to.tagName === "TEMPLATE" ? to.content : to;
const sourceNodes = from.tagName === "TEMPLATE" ? from.content.childNodes : from.childNodes;
Array.from(sourceNodes).forEach((child) => target.appendChild(child));
if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
}
function _makeComponent(name, node, scanObj, exists = {}) {
if (exists[name]) return;
exists[name] = true;
if (scanObj.thisObj) {
Array.from(node.attributes).forEach((attr) => {
if ((attr.name.startsWith("$") || attr.name.startsWith("st-")) && attr.value.includes("this.")) {
attr.value = attr.value.replace(/\bthis\./g, "this.parent.");
}
});
}
const componentFunc = Component.getSetupFunction(name);
const slots = {};
Array.from(node.childNodes).forEach((child) => {
if (child.nodeType === Node.ELEMENT_NODE && child.hasAttribute("slot")) {
slots[child.getAttribute("slot")] = child;
child.removeAttribute("slot");
}
});
node.innerHTML = "";
node.state = NewState(node.state || {});
const template = Component.getTemplate(name);
if (template) {
const tplnode = template.content.cloneNode(true);
if (tplnode.childNodes.length) {
const rootNode = tplnode.children[0];
if (rootNode) _mergeNode(rootNode, node, scanObj, exists);
$$(node, "[slot-id]").forEach((placeholder) => {
const slotName = placeholder.getAttribute("slot-id");
if (slots[slotName]) {
placeholder.removeAttribute("slot-id");
placeholder.innerHTML = "";
_mergeNode(slots[slotName], placeholder, scanObj, exists);
}
});
}
}
if (componentFunc) componentFunc(node);
}
let _disableRunCodeError = false;
function setDisableRunCodeError(value) {
_disableRunCodeError = value;
}
const _fnCache = /* @__PURE__ */ new Map();
function _runCode(code, vars, thisObj, extendVars) {
const allVars = { ...extendVars || {}, ...vars || {} };
const argKeys = Object.keys(allVars);
const argValues = Object.values(allVars);
const cacheKey = code + argKeys.join(",");
try {
let fn = _fnCache.get(cacheKey);
if (!fn) {
fn = new Function("Hash", "LocalStorage", "State", ...argKeys, code);
_fnCache.set(cacheKey, fn);
}
return fn.apply(thisObj, [globalThis.Hash, globalThis.LocalStorage, globalThis.State, ...argValues]);
} catch (e) {
if (!_disableRunCodeError) console.error(e, extendVars, [code, extendVars, vars, thisObj]);
return null;
}
}
function _returnCode(code, vars, thisObj, extendVars) {
if (code.includes("${")) return _runCode("return `" + code + "`", vars, thisObj, extendVars);
else return _runCode("return " + code, vars, thisObj, extendVars);
}
let _translator = (text, args) => {
if (!text || typeof text !== "string") return text;
return text.replace(/\{(.+?)\}/g, (match, key) => args.hasOwnProperty(key) ? args[key] : match);
};
const SetTranslator = (fn) => _translator = fn;
const _translate = (text) => {
if (!text || typeof text !== "string" || !text.includes("{#")) return text;
return text.replace(/\{#(.+?)#\}/g, (m, content) => {
const parts = content.split("||").map((s) => s.trim());
const args = {};
if (parts.length > 1) {
const matches = parts[0].match(/\{(.+?)\}/g);
if (matches) matches.forEach((match, i) => args[match.substring(1, match.length - 1)] = parts[i + 1] || "");
}
return _translator(parts[0], args);
});
};
if (typeof document !== "undefined") {
try {
document.createElement("div").setAttribute("$t", "1");
} catch (e) {
const originalSetAttribute = Element.prototype.setAttribute;
Element.prototype.setAttribute = function(name, value) {
if (!name.startsWith("$")) return originalSetAttribute.call(this, name, value);
return originalSetAttribute.call(this, "st-" + name.substring(1), value);
};
}
}
_onNotifyUpdate((binding) => _updateBinding(binding));
function _clearRenderedNodes(node) {
if (node._renderedNodes) node._renderedNodes.forEach((nodes) => nodes.forEach((child) => {
child.remove();
if (child._renderedNodes) _clearRenderedNodes(child);
}));
}
function _updateBinding(binding) {
const node = binding.node;
if (!node.isConnected && node.tagName !== "TEMPLATE") return;
_setActiveBinding(binding);
let result = binding.exp ? binding.tpl ? _returnCode(binding.tpl, { thisNode: node }, node._thisObj || node, node._ref || null) : null : binding.tpl;
if (binding.exp === 2 && typeof result === "string") {
try {
result = _returnCode(result, { thisNode: node }, node._thisObj || node, node._ref || null);
} catch (e) {
}
}
_setActiveBinding(null);
if (binding.prop) {
const prop = binding.prop;
let o = node;
for (let i = 0; i < prop.length - 1; i++) {
if (!prop[i]) continue;
if (o[prop[i]] == null) o[prop[i]] = {};
o = o[prop[i]];
if (typeof o !== "object") break;
}
if (typeof o === "object" && o !== null) {
const lk = prop[prop.length - 1];
if (lk) {
if (typeof result === "object" && result != null && !Array.isArray(result) && o[lk] == null) o[lk] = {};
const lo = o[lk];
if (typeof lo === "object" && lo != null && lo.__watch) Object.assign(lo, result);
else {
if (o[lk] !== result) o[lk] = result;
}
} else if (typeof result === "object" && result != null && !Array.isArray(result)) {
Object.assign(o, result);
}
}
} else if (binding.attr) {
const attr = binding.attr;
if (attr === "if") {
if (result) {
if (!node._renderedNodes || node._renderedNodes.length === 0) {
node._children.forEach((child) => {
node.parentNode.insertBefore(child, node);
child._ref = { ...node._ref };
child._thisObj = node._thisObj;
});
node._renderedNodes = [node._children];
}
} else {
_clearRenderedNodes(node);
node._renderedNodes = [];
}
} else if (attr === "each") {
if (result && typeof result === "object") {
const asName = node.getAttribute("as") || "item";
const indexName = node.getAttribute("index") || "index";
const keyName = node.getAttribute("key");
let keys, getVal;
if (result instanceof Map) {
keys = Array.from(result.keys());
getVal = (k) => result.get(k);
} else if (typeof result[Symbol.iterator] === "function") {
const arr = Array.isArray(result) ? result : Array.from(result);
keys = new Array(arr.length);
for (let i = 0; i < arr.length; i++) keys[i] = i;
getVal = (k) => arr[k];
} else {
keys = Object.keys(result);
getVal = (k) => result[k];
}
if (!node._keyedNodes) node._keyedNodes = /* @__PURE__ */ new Map();
const newKeyedNodes = /* @__PURE__ */ new Map();
const currentRenderedNodes = [];
keys.forEach((k, i) => {
const item = getVal(k);
const rawKey = keyName ? item && typeof item === "object" ? item[keyName] : item : k;
const keyVal = rawKey === void 0 || rawKey === null || newKeyedNodes.has(rawKey) ? `st_key_${i}` : rawKey;
let existingNodes = node._keyedNodes.get(keyVal);
if (existingNodes) {
node._keyedNodes.delete(keyVal);
existingNodes.forEach((child) => {
child._ref[indexName] = k;
child._ref[asName] = item;
_scanTree(child);
});
} else {
existingNodes = [];
node._children.forEach((child) => {
const cloned = child.cloneNode(true);
cloned._ref = { ...node._ref, [indexName]: k, [asName]: item };
cloned._thisObj = node._thisObj;
node.parentNode.insertBefore(cloned, node);
existingNodes.push(cloned);
});
}
newKeyedNodes.set(keyVal, existingNodes);
currentRenderedNodes.push(existingNodes);
});
node._keyedNodes.forEach((nodes) => nodes.forEach((child) => {
_clearRenderedNodes(child);
child.remove();
}));
node._keyedNodes = newKeyedNodes;
node._renderedNodes = currentRenderedNodes;
} else {
_clearRenderedNodes(node);
node._renderedNodes = [];
}
} else if (attr === "bind") {
if (["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) && !node.hasAttribute("autocomplete")) node.setAttribute("autocomplete", "off");
if (node.type === "checkbox") {
if (node.value !== "on" && !result) {
_runCode(`${binding.tpl} = []`, { thisNode: node }, node._thisObj || node, node._ref || {});
result = [];
}
node._checkboxMultiMode = result instanceof Array;
const isChecked = result instanceof Array ? result.includes(node.value) : !!result;
if (node.checked !== isChecked) node.checked = isChecked;
} else if (node.type === "radio") {
if (node.checked !== (node.value === String(result ?? ""))) node.checked = node.value === String(result ?? "");
} else if ("value" in node && node.type !== "file") {
Promise.resolve().then(() => {
if (node.value !== String(result ?? "")) node.value = result;
});
} else if (node.isContentEditable) {
if (node.innerHTML !== String(result ?? "")) node.innerHTML = result;
}
node.dispatchEvent(new CustomEvent("bind", { bubbles: false, detail: result }));
} else {
if (["checked", "disabled", "readonly"].includes(attr)) result = !!result;
if (typeof result === "boolean") result ? node.setAttribute(attr, "") : node.removeAttribute(attr);
else if (result !== void 0) {
if (typeof result !== "string") result = JSON.stringify(result);
if (attr === "text") node.textContent = result ?? "";
else if (attr === "html") node.innerHTML = result ?? "";
else if (node.tagName === "IMG" && attr === "src" && result.includes(".svg")) node.setAttribute("_src", result ?? "");
else node.setAttribute(attr, result ?? "");
}
}
}
}
const _initBinding = (binding) => {
if (!binding.node._bindings) binding.node._bindings = [];
binding.node._bindings.push({ attr: binding.attr, prop: binding.prop, tpl: binding.tpl, exp: binding.exp });
_updateBinding(binding);
};
const _parseNode = (node, scanObj) => {
if (node._bindings) {
node._states = /* @__PURE__ */ new Set();
node._bindings.forEach((b) => _updateBinding({ node, ...b }));
if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false }));
return;
}
if (Component.exists(node.tagName) && !node._componentInitialized) {
Array.from(node.attributes).forEach((attr) => {
var _a2;
if (attr.name.startsWith("$.")) {
const realAttrName = attr.name.slice(2);
let tpl = _translate(attr.value);
if (tpl.includes("this.")) tpl = tpl.replace(/\bthis\./g, "this.parent.");
const result = _returnCode(tpl, { thisNode: node }, { parent: scanObj.thisObj || node }, node._ref || {});
let o = node;
const prop = realAttrName.split(".");
for (let i = 0; i < prop.length - 1; i++) {
if (prop[i]) o = o[_a2 = prop[i]] ?? (o[_a2] = {});
}
o[prop[prop.length - 1]] = result;
node.removeAttribute(attr.name);
}
});
_makeComponent(node.tagName, node, scanObj);
$$(node, "[slot-id]").forEach((p) => p.removeAttribute("slot-id"));
node._componentInitialized = true;
if (!node._thisObj) node._thisObj = node;
}
if (node.tagName === "TEMPLATE") {
node._children = [...node.content.childNodes];
if (!node._renderedNodes) node._renderedNodes = [];
}
let attrs = [];
if (node.tagName === "TEMPLATE") {
["$if", "$each", "st-if", "st-each"].forEach((n) => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
} else {
attrs = Array.from(node.attributes).filter((a) => (a.name.startsWith("$") || a.name.startsWith("st-")) && !["$if", "$each", "st-if", "st-each"].includes(a.name) || a.name.includes("."));
}
if (node._thisObj && scanObj.thisObj) node._thisObj.parent = scanObj.thisObj;
if (!node._thisObj) node._thisObj = scanObj.thisObj || null;
if (!node._ref) node._ref = scanObj.extendVars || {};
node._states = /* @__PURE__ */ new Set();
attrs.forEach((attr) => {
let exp = 0;
if (attr.name.startsWith("$$") || attr.name.startsWith("st-st-")) exp = 2;
else if (attr.name.startsWith("$") || attr.name.startsWith("st-")) exp = 1;
const realAttrName = exp === 2 ? attr.name.startsWith("$$") ? attr.name.slice(2) : attr.name.slice(6) : exp === 1 ? attr.name.startsWith("$") ? attr.name.slice(1) : attr.name.slice(3) : attr.name;
let tpl = attr.value;
node.removeAttribute(attr.name);
if (realAttrName.startsWith(".")) _initBinding({ node, prop: realAttrName.split("."), tpl, exp });
else if (realAttrName.startsWith("on")) {
const eventName = realAttrName.slice(2);
if (eventName === "update") node._hasOnUpdate = true;
if (eventName === "load" && !["BODY", "IMG", "IFRAME"].includes(node.tagName)) node._hasOnLoad = true;
if (eventName === "unload" && !["BODY", "IMG", "IFRAME"].includes(node.tagName)) node._hasOnUnload = true;
node.addEventListener(eventName, (e) => _runCode(tpl, { event: e, thisNode: node, ...e.detail || {} }, scanObj.thisObj || node, node._ref || {}));
} else {
if (realAttrName === "bind") {
node.addEventListener(["textarea", "text", "password"].includes(node.type || "text") || node.isContentEditable ? "input" : "change", (e) => {
let newVal = node.isContentEditable ? e.target.innerHTML : node.type === "checkbox" ? e.target.checked : e.target.files || e.target.value || e.detail;
_setNoWriteBack(node);
setDisableRunCodeError(true);
if (node.type === "checkbox" && node._checkboxMultiMode) _runCode(`!!checked ? (!${tpl}.includes(val) && ${tpl}.push(val)) : (index = ${tpl}.indexOf(val), index > -1 && ${tpl}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
else _runCode(`${tpl} = val`, { val: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
setDisableRunCodeError(false);
_setNoWriteBack(null);
});
} else if (realAttrName === "text" && !tpl) {
tpl = node.textContent;
node.textContent = "";
}
if (tpl) {
tpl = _translate(tpl);
_initBinding({ node, attr: realAttrName, tpl, exp });
}
}
});
if (node._hasOnLoad || node._componentInitialized) Promise.resolve().then(() => node.dispatchEvent(new Event("load", { bubbles: false })));
if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false }));
if (node._thisObj) scanObj.thisObj = node._thisObj;
};
const _scanTree = (node, scanObj = {}) => {
if (node.nodeType === 3) {
if (node._stTranslated) return;
const translated = _translate(node.textContent);
if (translated !== node.textContent) node.textContent = translated;
node._stTranslated = true;
return;
}
if (node.nodeType !== 1) return;
if (!node._stTranslated) {
Array.from(node.attributes).forEach((attr) => {
if (!attr.name.startsWith("$") && !attr.name.startsWith("st-") && !attr.name.startsWith(".")) {
const translated = _translate(attr.value);
if (translated !== attr.value) attr.value = translated;
}
});
node._stTranslated = true;
}
if (node.tagName !== "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("$each") || node.hasAttribute("st-if") || node.hasAttribute("st-each"))) {
const template = document.createElement("TEMPLATE");
const attrs = Array.from(node.attributes).filter((attr) => ["$if", "$each", "st-if", "st-each"].includes(attr.name) || (node.hasAttribute("$each") || node.hasAttribute("st-each")) && ["as", "index"].includes(attr.name));
attrs.forEach((attr) => {
template.setAttribute(attr.name, attr.value);
node.removeAttribute(attr.name);
});
node.parentNode.insertBefore(template, node);
template.content.appendChild(node);
template._ref = node._ref;
return;
}
if (node.tagName === "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("st-if")) && (node.hasAttribute("$each") || node.hasAttribute("st-each"))) {
const template = document.createElement("TEMPLATE");
const attrs = Array.from(node.attributes).filter((attr2) => ["$if", "$each", "st-if", "st-each"].includes(attr2.name));
const attr = attrs[attrs.length - 1];
template.setAttribute(attr.name, attr.value);
node.removeAttribute(attr.name);
if (attr.name === "$each" || attr.name === "st-each") {
Array.from(node.attributes).filter((attr2) => ["as", "index"].includes(attr2.name)).forEach((attr2) => {
template.setAttribute(attr2.name, attr2.value);
node.removeAttribute(attr2.name);
});
}
Array.from(node.content.childNodes).forEach((child) => template.content.appendChild(child));
node.content.appendChild(template);
template._ref = node._ref;
}
if (node.tagName === "IMG" && (node.hasAttribute("src") || node.hasAttribute("_src") || node.hasAttribute("$src"))) {
const imgNode = node;
Promise.resolve().then(() => {
const url = imgNode.getAttribute("_src") || imgNode.getAttribute("src");
if (url) fetch(url, { cache: "force-cache" }).then((r) => r.text()).then((svgText) => {
const realSvg = new DOMParser().parseFromString(svgText, "image/svg+xml").querySelector("svg");
if (realSvg) {
Array.from(imgNode.attributes).forEach((attr) => realSvg.setAttribute(attr.name, attr.value));
imgNode.replaceWith(realSvg);
}
});
});
}
if (node._thisObj !== void 0) scanObj.thisObj = node._thisObj || null;
else {
let curr = node;
while (curr && curr._thisObj === void 0) curr = curr.parentNode;
scanObj.thisObj = curr ? curr._thisObj : null;
}
if (node._ref === void 0) {
let curr = node;
while (curr && curr._ref === void 0) curr = curr.parentNode;
node._ref = curr ? { ...curr._ref } : {};
}
if (scanObj.extendVars) Object.assign(node._ref, scanObj.extendVars);
_parseNode(node, { ...scanObj });
const nodes = [...node.childNodes || []];
nodes.forEach((child) => _scanTree(child, { thisObj: scanObj.thisObj, extendVars: { ...node._ref } }));
};
const _unbindTree = (node) => {
if (node.nodeType !== 1) return;
if (node._hasOnUnload) node.dispatchEvent(new Event("unload", { bubbles: false }));
if (node._states) node._states.forEach((mappings) => {
for (const [key, bindingSet] of mappings) {
for (const binding of bindingSet) {
if (binding.node === node) bindingSet.delete(binding);
}
}
});
node.childNodes && node.childNodes.forEach((child) => _unbindTree(child));
};
const _unsafeRefreshState = _scanTree;
const 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) => Util.base64(str).replace(/[+/=]/g, (m) => ({ "+": "-", "/": "", "=": "" })[m]),
unurlbase64: (str) => Util.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] === void 0) 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 = (/* @__PURE__ */ new Date()).getTime(),
end: () => {
const endTime = (/* @__PURE__ */ new Date()).getTime();
const left = endTime - startTime;
startTime = endTime;
total += left;
count++;
return left;
},
avg: () => total / count
};
}
};
globalThis.Util = Util;
let _hashParams = new URLSearchParams(((_a = window.location.hash) == null ? void 0 : _a.substring(1)) || "");
const Hash = NewState({}, (k) => Util.safeJson(_hashParams.get(k)), (k, v) => {
const oldStr = _hashParams.get(k);
const newStr = v === void 0 ? void 0 : JSON.stringify(v);
if (oldStr === newStr || oldStr === null && newStr === void 0) return;
v === void 0 ? _hashParams.delete(k) : _hashParams.set(k, newStr);
window.location.hash = "#" + _hashParams.toString();
});
if (typeof window !== "undefined") {
window.addEventListener("hashchange", () => {
var _a2;
const newParams = new URLSearchParams(((_a2 = window.location.hash) == null ? void 0 : _a2.substring(1)) || "");
const keys = /* @__PURE__ */ new Set([..._hashParams.keys(), ...newParams.keys()]);
_hashParams = newParams;
keys.forEach((k) => Hash[k] = Hash[k]);
});
}
const LocalStorage = NewState({}, (k) => Util.safeJson(localStorage.getItem(k)), (k, v) => {
const oldStr = localStorage.getItem(k);
const newStr = v === void 0 ? void 0 : JSON.stringify(v);
if (oldStr === newStr || oldStr === null && newStr === void 0) return;
v === void 0 ? localStorage.removeItem(k) : localStorage.setItem(k, newStr);
});
const State = NewState({
exitBlocks: 0
});
globalThis.Hash = Hash;
globalThis.LocalStorage = LocalStorage;
globalThis.State = State;
const ApigoState = {
NewState,
Component,
$,
$$,
RefreshState: _unsafeRefreshState,
SetTranslator,
_scanTree,
_unbindTree,
Util,
Hash,
LocalStorage,
State
};
if (typeof window !== "undefined") {
window.ApigoState = ApigoState;
}
if (typeof document !== "undefined") {
const init = () => {
Component._initPending();
const htmlNode = document.documentElement;
if (!htmlNode.hasAttribute("$data-bs-theme") && !htmlNode.hasAttribute("data-bs-theme")) {
htmlNode.setAttribute("$data-bs-theme", "LocalStorage.darkMode?'dark':'light'");
}
new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
mutation.addedNodes.forEach((newNode) => {
if (newNode.isConnected) _scanTree(newNode);
});
mutation.removedNodes.forEach((oldNode) => _unbindTree(oldNode));
});
}).observe(document.documentElement, { childList: true, subtree: true });
_scanTree(document.documentElement);
};
if (document.readyState !== "loading") init();
else document.addEventListener("DOMContentLoaded", init, true);
}
exports2.$ = $;
exports2.$$ = $$;
exports2.Component = Component;
exports2.Hash = Hash;
exports2.LocalStorage = LocalStorage;
exports2.NewState = NewState;
exports2.RefreshState = _unsafeRefreshState;
exports2.SetTranslator = SetTranslator;
exports2.State = State;
exports2.Util = Util;
exports2._scanTree = _scanTree;
exports2._unbindTree = _unbindTree;
Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
});

100
test/list_test.html Normal file
View File

@ -0,0 +1,100 @@
<!DOCTYPE html>
<html data-bs-theme="dark">
<head>
<meta charset="utf-8">
<title>List Mega Test (Virtual Scroll Stress Test)</title>
<script src="./lib/state.js"></script>
<script src="./lib/bootstrap.js"></script>
<script src="./lib/base.js"></script>
<style>
body { height: 100vh; background: #111; color: #eee; }
.test-container { display: grid; grid-template-columns: repeat(2, 1fr); grid-template-rows: repeat(2, 1fr); gap: 20px; height: 100%; padding: 20px; box-sizing: border-box; }
.list-card { display: flex; flex-direction: column; overflow: hidden; border: 1px solid #444; border-radius: 8px; background: #222; }
.list-card h5 { padding: 10px; margin: 0; background: #333; border-bottom: 1px solid #444; font-size: 14px; }
</style>
</head>
<body>
<div class="test-container">
<div class="list-card">
<h5>1. Standard List (Normal)</h5>
<List id="listStd" class="flex-fill" $.state.list="State.stdItems" auto-select></List>
</div>
<div class="list-card">
<h5>2. Group List (Mode: Group)</h5>
<List id="listGrp" mode="group" class="flex-fill" $.state.list="State.grpItems" $.state.groups="State.groups"></List>
</div>
<div class="list-card">
<h5>3. Tree List (Mode: Tree + Collapsible)</h5>
<List id="listTree" mode="tree" collapsible class="flex-fill" $.state.list="State.treeItems"></List>
</div>
<div class="list-card">
<h5>4. FAST Virtual List (10,000 Items + Dynamic Height)</h5>
<List id="listFast" fast class="flex-fill" $.state.list="State.bigItems" auto-select>
<!-- 自定义插槽:必须保留 list-group-item 类和 $onupdate 钩子 -->
<template slot="item">
<div $onupdate="this.onItemUpdate(index, thisNode)"
$class="list-group-item list-group-item-action d-flex flex-column w-100 py-2 \${this.state?.selectedItem===item.id?'active':''}"
$onclick="this.selectItem(item,index)">
<div class="d-flex align-items-center">
<i class="bi bi-person-circle me-2 text-primary"></i>
<span class="fw-bold" $text="item.label"></span>
<span class="ms-auto badge bg-dark text-muted" $text="'#' + item.id"></span>
</div>
<div class="small text-secondary mt-1" style="white-space: normal; line-height: 1.4" $text="item.summary"></div>
</div>
</template>
</List>
</div>
</div>
<script>
// 1. 准备标准数据
const stdItems = [];
for(let i=1; i<=50; i++) stdItems.push({ id: i, label: 'Item ' + i, summary: 'This is standard item ' + i });
const groups = [
{ id: 'g1', label: 'Engineering', summary: 'Technical staff' },
{ id: 'g2', label: 'Marketing', summary: 'Growth team' },
{ id: 'g3', label: 'Sales', summary: 'Revenue drivers' }
];
const grpItems = [];
for(let i=1; i<=100; i++) {
grpItems.push({ id: i, label: 'Member ' + i, group: i % 3 === 0 ? 'g1' : (i % 3 === 1 ? 'g2' : 'g3') });
}
const treeItems = [
{ id: 'root1', label: 'Company HQ', parent: '' },
{ id: 'dept1', label: 'R&D Division', parent: 'root1' },
{ id: 'team1', label: 'Framework Team', parent: 'dept1' },
{ id: 'team2', label: 'UI Team', parent: 'dept1' },
{ id: 'dept2', label: 'Operations', parent: 'root1' },
{ id: 'team3', label: 'Cloud Ops', parent: 'dept2' },
{ id: 'root2', label: 'Overseas Branch', parent: '' },
{ id: 'dept3', label: 'Asia Pacific', parent: 'root2' }
];
// 2. 准备 10,000 条极端高度差异数据
const bigItems = [];
const lorem = [
"Short summary.",
"Medium length summary that might take two lines in a narrow container to test dynamic height measurement accurately. We are checking if the VirtualScroll correctly captures this.",
"EXTREME HEIGHT TEST: " + new Array(15).fill("This is a very long line of text intended to force the container to expand significantly. ").join(" ") + " This block should be at least 200px-300px high depending on the width.",
"Normal sentence for baseline."
];
for(let i=1; i<=10000; i++) {
bigItems.push({
id: i,
label: 'Virtual Item ' + i,
summary: lorem[i % 4]
});
}
State.stdItems = stdItems;
State.groups = groups;
State.grpItems = grpItems;
State.treeItems = treeItems;
State.bigItems = bigItems;
</script>
</body>
</html>

59
test/mega_verify.spec.js Normal file
View File

@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test';
test('Empirical Mega Verification', async ({ page }) => {
page.on('console', msg => console.log('BROWSER:', msg.text()));
// 1. Verify AutoForm
console.log('--- Verifying AutoForm Mega ---');
await page.goto('http://localhost:5173/test/form_test.html');
await page.waitForTimeout(3000);
const checkForm = async (id) => {
return await page.evaluate((fid) => {
const form = document.getElementById(fid);
const inputs = form.querySelectorAll('input, select, textarea');
const labels = form.querySelectorAll('label');
return {
id: fid,
inputCount: inputs.length,
labelCount: labels.length,
html: form.innerHTML.substring(0, 100)
};
}, id);
};
const vResult = await checkForm('formV');
const hResult = await checkForm('formH');
const iResult = await checkForm('formI');
console.log('Form results:', { vResult, hResult, iResult });
expect(vResult.inputCount).toBeGreaterThan(5);
expect(hResult.inputCount).toBeGreaterThan(5);
// 2. Verify List
console.log('--- Verifying List Mega ---');
await page.goto('http://localhost:5173/test/list_test.html');
await page.waitForTimeout(3000);
const checkList = async (id) => {
return await page.evaluate((lid) => {
const list = document.getElementById(lid);
const items = list.querySelectorAll('.list-group-item');
return {
id: lid,
itemCount: items.length,
labels: Array.from(items).map(i => i.textContent.trim())
};
}, id);
};
const stdResult = await checkList('listStd');
const grpResult = await checkList('listGrp');
const treeResult = await checkList('listTree');
console.log('List results:', { stdResult, grpResult, treeResult });
expect(stdResult.itemCount).toBeGreaterThan(0);
expect(grpResult.itemCount).toBeGreaterThan(2);
expect(treeResult.itemCount).toBeGreaterThan(0);
});

View File

@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test';
test('Stress test: Virtual List should handle 10,000 items with dynamic height', async ({ page }) => {
test.setTimeout(60000);
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
await page.goto('http://localhost:5174/test/list_test.html');
const listFast = page.locator('#listFast');
await expect(listFast).toBeVisible();
await page.waitForFunction(() => document.querySelectorAll('#listFast .list-group-item').length > 0);
// Check initial state
const scrollTop = await listFast.evaluate(e => e.scrollTop);
const scrollHeight = await listFast.evaluate(e => e.scrollHeight);
const clientHeight = await listFast.evaluate(e => e.clientHeight);
console.log(`Initial: scrollTop=${scrollTop}, scrollHeight=${scrollHeight}, clientHeight=${clientHeight}`);
// Attach event listener to see if scroll fires
await listFast.evaluate(e => {
e.addEventListener('scroll', () => console.log('SCROLL EVENT FIRED! new scrollTop:', e.scrollTop));
});
// Scroll to the very end
await listFast.evaluate(e => e.scrollTop = e.scrollHeight);
await page.waitForTimeout(1000);
const finalScrollTop = await listFast.evaluate(e => e.scrollTop);
console.log(`Final: scrollTop=${finalScrollTop}`);
const lastItemText = await listFast.locator('.list-group-item').last().textContent();
console.log('Last rendered item text:', lastItemText);
expect(lastItemText).toContain('Virtual Item 10000');
});

20
test/verify_style.spec.js Normal file
View File

@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';
test('Check styles and globals', async ({ page }) => {
await page.goto('http://localhost:5174/test/form_test.html');
// Check if auto-grid-form is grid (horizontal mode)
const formH = page.locator('#formH form');
const display = await formH.evaluate(el => window.getComputedStyle(el).display);
console.log('formH display:', display);
expect(display).toBe('grid');
// Get color picker input to check height
const colorInput = page.locator('#formH input[type="color"]');
if (await colorInput.count() > 0) {
const height = await colorInput.evaluate(el => window.getComputedStyle(el).height);
console.log('colorInput height:', height);
// It shouldn't be very small (like 0 or 2px)
expect(parseInt(height)).toBeGreaterThan(20);
}
});

View File

@ -19,7 +19,7 @@ export default defineConfig({
lib: {
entry: resolve(__dirname, 'src/index.js'),
name: 'ApigoBase',
formats: ['umd', 'es']
formats: ['umd']
},
rollupOptions: {
external: ['@apigo.cc/state', '@apigo.cc/bootstrap'],
@ -42,15 +42,6 @@ export default defineConfig({
'@apigo.cc/bootstrap': 'bootstrap'
},
plugins: [terser()]
},
{
format: 'es',
entryFileNames: 'base.mjs'
},
{
format: 'es',
entryFileNames: 'base.min.mjs',
plugins: [terser()]
}
]
},