chore: release v1.0.13, clean ESM and fix autoform. By: AICoder
This commit is contained in:
parent
251bb617e5
commit
bae8adb2d3
@ -1,5 +1,13 @@
|
|||||||
# Changelog
|
# 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
|
## [1.0.12] - 2026-06-05
|
||||||
### Changed
|
### Changed
|
||||||
- **De-ESM Refactor**: Fully transitioned the runtime environment to synchronous UMD loading, eliminating race conditions and rendering artifacts.
|
- **De-ESM Refactor**: Fully transitioned the runtime environment to synchronous UMD loading, eliminating race conditions and rendering artifacts.
|
||||||
|
|||||||
17
README.md
17
README.md
@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
### ESM 模块引入 (Legacy)
|
### ESM 模块引入 (Legacy)
|
||||||
```javascript
|
```javascript
|
||||||
import { HTTP, UI, AutoForm, State } from '@apigo.cc/base'
|
const { HTTP, UI, AutoForm, State } = window.ApigoBase
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -99,7 +99,7 @@ import { HTTP, UI, AutoForm, State } from '@apigo.cc/base'
|
|||||||
<!-- 2. HTML 声明式属性绑定 schema 状态,且绑定接口 API 实例 -->
|
<!-- 2. HTML 声明式属性绑定 schema 状态,且绑定接口 API 实例 -->
|
||||||
<AutoForm id="employeeForm"
|
<AutoForm id="employeeForm"
|
||||||
vertical
|
vertical
|
||||||
$state.schema="employeeFormSchema"
|
$.state.schema="employeeFormSchema"
|
||||||
$api="saveEmployeeApi"
|
$api="saveEmployeeApi"
|
||||||
$onresponse="UI.toast('保存成功!')">
|
$onresponse="UI.toast('保存成功!')">
|
||||||
</AutoForm>
|
</AutoForm>
|
||||||
@ -157,7 +157,7 @@ import { HTTP, UI, AutoForm, State } from '@apigo.cc/base'
|
|||||||
```
|
```
|
||||||
* **HTML 写法**:
|
* **HTML 写法**:
|
||||||
```html
|
```html
|
||||||
<List mode="group" $list="groupUserList" $state.groups="deptGroups"></List>
|
<List mode="group" $list="groupUserList" $.state.groups="deptGroups"></List>
|
||||||
```
|
```
|
||||||
|
|
||||||
#### 模式三:树形可折叠列表 (`mode="tree"`) —— 超级示例
|
#### 模式三:树形可折叠列表 (`mode="tree"`) —— 超级示例
|
||||||
@ -222,8 +222,8 @@ import { HTTP, UI, AutoForm, State } from '@apigo.cc/base'
|
|||||||
<!-- 2. HTML 声明式属性绑定 -->
|
<!-- 2. HTML 声明式属性绑定 -->
|
||||||
<Nav id="sidebarNav"
|
<Nav id="sidebarNav"
|
||||||
vertical
|
vertical
|
||||||
$state.brand="myBrand"
|
$.state.brand="myBrand"
|
||||||
$state.list="myNavList"
|
$.state.list="myNavList"
|
||||||
$onnav="console.log('导航点击:', event.detail.item)">
|
$onnav="console.log('导航点击:', event.detail.item)">
|
||||||
</Nav>
|
</Nav>
|
||||||
```
|
```
|
||||||
@ -289,6 +289,7 @@ const resp = await HTTP.post({
|
|||||||
## 4. 开发红线 (Constraints)
|
## 4. 开发红线 (Constraints)
|
||||||
|
|
||||||
1. **表单数据操作红线**:严禁直接覆盖表单的 `state.data` 或 `data` 对象(如 `form.data = {}`)。这会切断与内部 Proxy 的响应式链路。必须使用 `Object.assign(form.state.data, newData)`。
|
1. **表单数据操作红线**:严禁直接覆盖表单的 `state.data` 或 `data` 对象(如 `form.data = {}`)。这会切断与内部 Proxy 的响应式链路。必须使用 `Object.assign(form.state.data, newData)`。
|
||||||
2. **列表布局红线**:开启虚拟滚动(`fast`)时,容器必须包含 `overflow-auto` 类。
|
2. **结构化指令红线**:所有 `$if` 与 `$each` 指令 **必须** 显式作用于 `<template>` 标签,且严禁在同一个 `<template>` 上同时使用两者(必须嵌套)。
|
||||||
3. **指令 DOM 保护**:严禁使用原生 DOM API 直接修改由 `$each`、`$if` 或组件渲染指令生成的 DOM 节点。所有 DOM 状态的变化应当完全通过修改与之绑定的底层 `State` 属性驱动。
|
3. **列表布局红线**:开启虚拟滚动(`fast`)时,容器必须包含 `overflow-auto` 类。
|
||||||
4. **退出拦截约束**:在全局配置有 `State.exitBlocks > 0` 时,框架将强行拦截并警告任何刷新/关闭页面的行为。
|
4. **指令 DOM 保护**:严禁使用原生 DOM API 直接修改由 `$each`、`$if` 或组件渲染指令生成的 DOM 节点。所有 DOM 状态的变化应当完全通过修改与之绑定的底层 `State` 属性驱动。
|
||||||
|
5. **退出拦截约束**:在全局配置有 `State.exitBlocks > 0` 时,框架将强行拦截并警告任何刷新/关闭页面的行为。
|
||||||
|
|||||||
277
dist/base.js
vendored
277
dist/base.js
vendored
File diff suppressed because one or more lines are too long
2
dist/base.min.js
vendored
2
dist/base.min.js
vendored
File diff suppressed because one or more lines are too long
1
dist/base.min.mjs
vendored
1
dist/base.min.mjs
vendored
File diff suppressed because one or more lines are too long
961
dist/base.mjs
vendored
961
dist/base.mjs
vendored
File diff suppressed because one or more lines are too long
@ -1,9 +1,8 @@
|
|||||||
{
|
{
|
||||||
"name": "@apigo.cc/base",
|
"name": "@apigo.cc/base",
|
||||||
"version": "1.0.11",
|
"version": "1.0.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/base.js",
|
"main": "dist/base.js",
|
||||||
"module": "dist/base.js",
|
|
||||||
"files": [
|
"files": [
|
||||||
"dist"
|
"dist"
|
||||||
],
|
],
|
||||||
|
|||||||
7
src/controls.js
vendored
7
src/controls.js
vendored
@ -6,6 +6,7 @@ import { AutoForm } from './form.js'
|
|||||||
* 支持单日期及范围选择 (主字段 + 影子字段模式)
|
* 支持单日期及范围选择 (主字段 + 影子字段模式)
|
||||||
*/
|
*/
|
||||||
Component.register('DatePicker', container => {
|
Component.register('DatePicker', container => {
|
||||||
|
container._thisObj = container;
|
||||||
container.state = NewState({ start: '', end: '' })
|
container.state = NewState({ start: '', end: '' })
|
||||||
|
|
||||||
container.addEventListener('bind', e => {
|
container.addEventListener('bind', e => {
|
||||||
@ -63,6 +64,7 @@ Component.register('DatePicker', container => {
|
|||||||
* 支持颜色选择与十六进制文本输入
|
* 支持颜色选择与十六进制文本输入
|
||||||
*/
|
*/
|
||||||
Component.register('ColorPicker', container => {
|
Component.register('ColorPicker', container => {
|
||||||
|
container._thisObj = container;
|
||||||
container.state = NewState({ value: '#000000' })
|
container.state = NewState({ value: '#000000' })
|
||||||
container.addEventListener('bind', e => {
|
container.addEventListener('bind', e => {
|
||||||
container.state.value = e.detail || '#000000'
|
container.state.value = e.detail || '#000000'
|
||||||
@ -77,8 +79,8 @@ Component.register('ColorPicker', container => {
|
|||||||
}
|
}
|
||||||
}, Util.makeDom(/*html*/`
|
}, Util.makeDom(/*html*/`
|
||||||
<div class="d-flex align-items-center gap-2 w-100 h-100">
|
<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="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 h-100" $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>
|
</div>
|
||||||
`))
|
`))
|
||||||
|
|
||||||
@ -89,6 +91,7 @@ const BOOTSTRAP_ICONS = ['alarm', 'archive', 'arrow-left', 'arrow-right', 'bag',
|
|||||||
* 基于 Bootstrap Icons 的可视化选择控件
|
* 基于 Bootstrap Icons 的可视化选择控件
|
||||||
*/
|
*/
|
||||||
Component.register('IconPicker', container => {
|
Component.register('IconPicker', container => {
|
||||||
|
container._thisObj = container;
|
||||||
container.state = NewState({ value: '', search: '', open: false })
|
container.state = NewState({ value: '', search: '', open: false })
|
||||||
container.addEventListener('bind', e => {
|
container.addEventListener('bind', e => {
|
||||||
container.state.value = e.detail || ''
|
container.state.value = e.detail || ''
|
||||||
|
|||||||
234
src/form.js
234
src/form.js
@ -1,30 +1,81 @@
|
|||||||
import { Component, NewState, Util, $, State } from '@apigo.cc/state'
|
import { Component, NewState, Util, $, State } from '@apigo.cc/state'
|
||||||
import { HTTP } from './http.js'
|
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 => {
|
Component.register('AutoForm', container => {
|
||||||
if (!container.state.schema) container.state.schema = []
|
if (!container.state.schema) container.state.schema = []
|
||||||
|
|
||||||
// TRY: 确保 state.data 始终是响应式 Proxy,支持深层嵌套属性的监听
|
|
||||||
const ensureProxy = v => (v && typeof v === 'object' && !v.__isProxy) ? NewState(v) : v;
|
const ensureProxy = v => (v && typeof v === 'object' && !v.__isProxy) ? NewState(v) : v;
|
||||||
container.state.__watch('data', v => container.data = ensureProxy(v));
|
container.state.__watch('data', v => container.data = ensureProxy(v));
|
||||||
container.data = ensureProxy(container.state.data || {});
|
container.data = ensureProxy(container.state.data || {});
|
||||||
|
|
||||||
container.vertical = container.hasAttribute('vertical')
|
container.vertical = container.hasAttribute('vertical')
|
||||||
|
container.horizontal = container.hasAttribute('horizontal')
|
||||||
container.inline = container.hasAttribute('inline')
|
container.inline = container.hasAttribute('inline')
|
||||||
|
container.nobutton = container.hasAttribute('nobutton')
|
||||||
container.request = { method: 'POST' }
|
container.request = { method: 'POST' }
|
||||||
container.response = {}
|
container.response = {}
|
||||||
container.result = null
|
container.result = null
|
||||||
|
|
||||||
// 初始化数据联动:如果处于行编辑模式(inline),监听全局编辑状态
|
|
||||||
if (container.inline) {
|
|
||||||
State.__watch('editingData', (data) => {
|
|
||||||
container.data = data
|
|
||||||
})
|
|
||||||
State.__watch('editingSchema', (schema) => {
|
|
||||||
container.state.schema = schema
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
container.form = $(container, 'form')
|
container.form = $(container, 'form')
|
||||||
container.submit = (opt = {}) => {
|
container.submit = (opt = {}) => {
|
||||||
if (!container.form.reportValidity()) return globalThis.UI?.toast?.('{#verify failed#}', { type: 'danger' })
|
if (!container.form.reportValidity()) return globalThis.UI?.toast?.('{#verify failed#}', { type: 'danger' })
|
||||||
@ -44,76 +95,26 @@ Component.register('AutoForm', container => {
|
|||||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
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' : ''}">
|
container.checkIf = (item) => {
|
||||||
<form $class="w-100 d-flex flex-fill \${this.inline ? 'align-items-stretch h-100' : (this.vertical ? 'flex-column' : 'auto-grid-form')}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
if (!item.if) return true
|
||||||
<template $each="this.state.schema || []">
|
try {
|
||||||
<template $if="item.if || 'true'">
|
const fn = new Function('Hash', 'LocalStorage', 'State', 'item', 'data', 'return ' + item.if)
|
||||||
<div style="display:contents">
|
return fn.call(container, globalThis.Hash, globalThis.LocalStorage, globalThis.State, item, container.data)
|
||||||
<template $if="!this.inline">
|
} catch (e) {
|
||||||
<label $name="item.name" class="col-form-label text-muted" $text="item.label"></label>
|
return false
|
||||||
</template>
|
}
|
||||||
<div control-wrapper $class="\${this.inline ? 'flex-grow-1 h-100 d-flex align-items-center' : 'mb-3'}">
|
}
|
||||||
<template $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)">
|
}, AUTOFORM_BLUEPRINT, AUTOFORM_STYLE)
|
||||||
<input $name="item.name" $type="item.type" $.="item.setting || {}" $bind="this.data[item.name]" $class="form-control h-100 \${item.type === 'number' ? 'text-end' : ''}">
|
|
||||||
</template>
|
const findAnchorInBlueprint = (root) => {
|
||||||
<template $if="item.type === 'select'">
|
let f = root.querySelector('[control-wrapper]');
|
||||||
<select $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="form-select h-100">
|
if (f) return f;
|
||||||
<template $if="item.placeholder">
|
for (const t of root.querySelectorAll('template')) {
|
||||||
<option value="" $text="item.placeholder" disabled selected></option>
|
f = findAnchorInBlueprint(t.content); if (f) return f;
|
||||||
</template>
|
}
|
||||||
<template $each="item.options" as="opt">
|
return null;
|
||||||
<option $value="opt.value || opt" $text="opt.label || opt"></option>
|
}
|
||||||
</template>
|
|
||||||
</select>
|
|
||||||
</template>
|
|
||||||
<template $if="['checkbox', 'radio'].includes(item.type)">
|
|
||||||
<div $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;">
|
|
||||||
<template $each="item.options || [item.text||item.label||item.name]" as="option">
|
|
||||||
<label $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]">
|
|
||||||
<template $if="!this.inline || (item.options && item.options.length > 0)">
|
|
||||||
<span $text="option" class="form-check-label"></span>
|
|
||||||
</template>
|
|
||||||
</label>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template $if="item.type === 'switch'">
|
|
||||||
<div $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]">
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template $if="item.type === 'textarea'">
|
|
||||||
<textarea $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.data[item.name]"></textarea>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<!-- 扩展控件容器 -->
|
|
||||||
<template $each="AutoForm.customTypes" as="ext">
|
|
||||||
<template $if="item.type?.toLowerCase() === ext.typeName.toLowerCase()">
|
|
||||||
<div $is="ext.name" $name="item.name" $.="item.setting || {}" $bind="this.data[item.name]" class="h-100"></div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<template $if="!this.inline">
|
|
||||||
<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>
|
|
||||||
</template>
|
|
||||||
</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>`))
|
|
||||||
|
|
||||||
export const AutoForm = {
|
export const AutoForm = {
|
||||||
customTypes: [],
|
customTypes: [],
|
||||||
@ -121,54 +122,57 @@ export const AutoForm = {
|
|||||||
const type = typeName || name
|
const type = typeName || name
|
||||||
if (!AutoForm.customTypes.find(t => t.name === name)) {
|
if (!AutoForm.customTypes.find(t => t.name === name)) {
|
||||||
AutoForm.customTypes.push({ name, typeName: type })
|
AutoForm.customTypes.push({ name, typeName: type })
|
||||||
|
AutoForm._addAutoFormComponent(name, type)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
_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 => {
|
Component.register('TagsInput', container => {
|
||||||
container.state = NewState({ tags: [] })
|
container._thisObj = container; // 夺回上下文主权
|
||||||
container.addEventListener('bind', e => {
|
container.addEventListener('bind', e => {
|
||||||
container.state.tags = Array.isArray(e.detail) ? e.detail : []
|
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*/`
|
}, 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;">
|
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text">
|
||||||
<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) {
|
<template $each="this.state.tags">
|
||||||
if (['Backspace', 'Delete'].includes(event.key)) {
|
<button type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown='${Util.getFunctionBody(function (event) {
|
||||||
event.preventDefault()
|
if (['Backspace', 'Delete'].includes(event.key)) {
|
||||||
if (this.state?.tags) {
|
event.preventDefault()
|
||||||
this.state.tags.splice(index, 1)
|
this.state.tags.splice(index, 1)
|
||||||
this.state.tags = this.state.tags
|
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(() => {
|
Promise.resolve().then(() => {
|
||||||
const buttons = this.querySelectorAll('button');
|
const buttons = $$(this, 'button');
|
||||||
if (buttons.length > 0) (buttons[index > 0 ? index - 1 : 0] || buttons[0]).focus()
|
(buttons.length > 0 ? buttons[index > 0 ? index - 1 : 0] : $(this, 'input')).focus()
|
||||||
else { const input = $(this, 'input'); if (input) input.focus(); }
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
})}' $text="item"></button>
|
||||||
})}" $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; height: 32px" $onkeydown="${Util.getFunctionBody(function (event) {
|
<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 (event.isComposing) return
|
||||||
if (['Enter', ',', ' '].includes(event.key)) {
|
if (['Enter', ',', ' '].includes(event.key)) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const v = thisNode.value.trim()
|
const v = thisNode.value.trim()
|
||||||
if (v && this.state?.tags && !this.state.tags.includes(v)) {
|
if (v && !this.state.tags.includes(v)) {
|
||||||
this.state.tags.push(v)
|
this.state.tags.push(v)
|
||||||
this.state.tags = this.state.tags
|
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 }))
|
||||||
|
}
|
||||||
|
thisNode.value = ''
|
||||||
}
|
}
|
||||||
thisNode.value = ''
|
})}'>
|
||||||
}
|
|
||||||
})}">
|
|
||||||
</div>
|
</div>
|
||||||
`), Util.makeDom(/*html*/`<style>
|
`), Util.makeDom(/*html*/`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</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>`))
|
|
||||||
|
|
||||||
AutoForm.register('TagsInput')
|
AutoForm.register('TagsInput')
|
||||||
|
|||||||
69
src/list.js
69
src/list.js
@ -16,9 +16,6 @@ export const VirtualScroll = (options = {}) => {
|
|||||||
const size = list.length; groupItemCount = Math.ceil(Math.sqrt(size)) || 10;
|
const size = list.length; groupItemCount = Math.ceil(Math.sqrt(size)) || 10;
|
||||||
const style = window.getComputedStyle(container);
|
const style = window.getComputedStyle(container);
|
||||||
padTop = parseFloat(style.paddingTop) || 0; rowGap = parseFloat(style.rowGap) || 0;
|
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)));
|
const visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (providedItemHeight || 32)));
|
||||||
return list.slice(0, Math.min(visibleCount * 3, size));
|
return list.slice(0, Math.min(visibleCount * 3, size));
|
||||||
},
|
},
|
||||||
@ -26,19 +23,13 @@ export const VirtualScroll = (options = {}) => {
|
|||||||
if (listInited) return;
|
if (listInited) return;
|
||||||
const size = list.length;
|
const size = list.length;
|
||||||
let defaultHeight = providedItemHeight || avg.get() || 32;
|
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) {
|
if (size > 0 && typeof list[0] === 'object' && list[0] !== null && list[0]._itemHeight) {
|
||||||
defaultHeight = list[0]._itemHeight;
|
defaultHeight = list[0]._itemHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
avg.add(defaultHeight);
|
avg.add(defaultHeight);
|
||||||
if (itemMarginTop === null) { itemMarginTop = 0; itemMarginBottom = 0; }
|
if (itemMarginTop === null) { itemMarginTop = 0; itemMarginBottom = 0; }
|
||||||
|
|
||||||
for (let i = 0; i < size; i++) {
|
for (let i = 0; i < size; i++) {
|
||||||
if (!itemHeights.has(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;
|
const ih = (typeof list[i] === 'object' && list[i] !== null && list[i]._itemHeight) ? list[i]._itemHeight : defaultHeight;
|
||||||
itemHeights.set(i, ih);
|
itemHeights.set(i, ih);
|
||||||
}
|
}
|
||||||
@ -72,14 +63,11 @@ export const VirtualScroll = (options = {}) => {
|
|||||||
let loopCount = 0;
|
let loopCount = 0;
|
||||||
|
|
||||||
for (let i = 0; i < size; i++) {
|
for (let i = 0; i < size; i++) {
|
||||||
if (++loopCount > size * 2) {
|
if (++loopCount > size * 2) throw new Error('VirtualScroll infinite loop');
|
||||||
throw new Error(`VirtualScroll.calc infinite loop detected at i=${i}, status=${status}, size=${size}, groupItemCount=${groupItemCount}`);
|
|
||||||
}
|
|
||||||
if (status === 0) {
|
if (status === 0) {
|
||||||
const gh = groupHeights.get(i);
|
const gh = groupHeights.get(i);
|
||||||
if (gh && prev + gh <= scrollTop && (i + groupItemCount < size)) {
|
if (gh && prev + gh <= scrollTop && (i + groupItemCount < size)) {
|
||||||
prev += gh;
|
prev += gh; i += groupItemCount - 1;
|
||||||
i += groupItemCount - 1;
|
|
||||||
} else {
|
} else {
|
||||||
const ih = itemHeights.get(i);
|
const ih = itemHeights.get(i);
|
||||||
if (prev + ih <= scrollTop && i < size - 1) {
|
if (prev + ih <= scrollTop && i < size - 1) {
|
||||||
@ -96,19 +84,11 @@ export const VirtualScroll = (options = {}) => {
|
|||||||
}
|
}
|
||||||
} else if (status === 1) {
|
} else if (status === 1) {
|
||||||
const gh = groupHeights.get(i);
|
const gh = groupHeights.get(i);
|
||||||
if (gh) {
|
if (gh) { post += gh; i += groupItemCount - 1; }
|
||||||
post += gh;
|
else post += itemHeights.get(i);
|
||||||
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));
|
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;
|
const finalPostHeight = post > 0 ? Math.max(0, post - 2 * rowGap) : 0;
|
||||||
return { prevHeight: finalPrevHeight, postHeight: finalPostHeight, renderedList, listStartIndex };
|
return { prevHeight: finalPrevHeight, postHeight: finalPostHeight, renderedList, listStartIndex };
|
||||||
}
|
}
|
||||||
@ -129,7 +109,8 @@ Component.register('List', container => {
|
|||||||
parentfield: 'parent', groupicon: 'folder', itemicon: 'file'
|
parentfield: 'parent', groupicon: 'folder', itemicon: 'file'
|
||||||
}
|
}
|
||||||
container.collapsed = NewState({})
|
container.collapsed = NewState({})
|
||||||
|
container.state.renderedList = []
|
||||||
|
|
||||||
const updateFlatList = () => {
|
const updateFlatList = () => {
|
||||||
Util.updateDefaults(container, defaultSets)
|
Util.updateDefaults(container, defaultSets)
|
||||||
const list = container.state.list || [], flatList = []
|
const list = container.state.list || [], flatList = []
|
||||||
@ -152,52 +133,44 @@ Component.register('List', container => {
|
|||||||
})
|
})
|
||||||
traverse(childrenMap[''] || [], 0, [])
|
traverse(childrenMap[''] || [], 0, [])
|
||||||
} else list.forEach(item => flatList.push({ type: 'item', ...item }))
|
} else list.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||||
container.state._flatList = flatList
|
container.state.flatList = flatList
|
||||||
}
|
}
|
||||||
|
|
||||||
container.state.__watch('list', updateFlatList)
|
container.state.__watch('list', updateFlatList)
|
||||||
const vs = container.fast ? VirtualScroll() : null
|
const vs = container.fast ? VirtualScroll() : null
|
||||||
container.state._renderedList = []
|
|
||||||
|
|
||||||
let refreshing = false
|
let refreshing = false
|
||||||
container.refresh = () => {
|
container.refresh = () => {
|
||||||
if (!container.fast || refreshing) return
|
if (!container.fast || refreshing) return
|
||||||
refreshing = true
|
refreshing = true
|
||||||
try {
|
try {
|
||||||
const res = vs.calc(container, container.state._flatList)
|
const res = vs.calc(container, container.state.flatList)
|
||||||
if (res) {
|
if (res) {
|
||||||
if (padTopEl) padTopEl.style.height = `${res.prevHeight}px`
|
if (padTopEl) padTopEl.style.height = `${res.prevHeight}px`
|
||||||
if (padBottomEl) padBottomEl.style.height = `${res.postHeight}px`
|
if (padBottomEl) padBottomEl.style.height = `${res.postHeight}px`
|
||||||
|
container.state.listStartIndex = res.listStartIndex
|
||||||
if (container.state._listStartIndex !== res.listStartIndex) {
|
container.state.renderedList = res.renderedList
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setTimeout(() => { refreshing = false }, 0)
|
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 (container.fast) {
|
||||||
if (padTopEl) padTopEl.style.height = '0px'
|
if (padTopEl) padTopEl.style.height = '0px'
|
||||||
if (padBottomEl) padBottomEl.style.height = '0px'
|
if (padBottomEl) padBottomEl.style.height = '0px'
|
||||||
container.state._listStartIndex = 0
|
container.state.listStartIndex = 0
|
||||||
container.state._renderedList = vs.reset(flatList, container) || []
|
container.state.renderedList = vs.reset(flatList, container) || []
|
||||||
setTimeout(() => { if (container.state._flatList === flatList) vs.init(flatList, container.refresh) })
|
setTimeout(() => { if (container.state.flatList === flatList) vs.init(flatList, container.refresh) })
|
||||||
} else container.state._renderedList = flatList
|
} else container.state.renderedList = flatList
|
||||||
})
|
})
|
||||||
|
|
||||||
container.selectItem = (item, index) => {
|
container.selectItem = (item, index) => {
|
||||||
if (container.hasAttribute('auto-select')) container.state.selectedItem = container.state.selectedItem === item[container.idfield] ? null : item[container.idfield]
|
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) => {
|
container.selectGroup = (item, index) => {
|
||||||
if (container.hasAttribute('auto-select-group')) container.state.selectedGroup = container.state.selectedGroup === item[container.groupidfield] ? null : item[container.groupidfield]
|
if (container.hasAttribute('auto-select-group')) container.state.selectedGroup = container.state.selectedGroup === item[container.groupidfield] ? null : item[container.groupidfield]
|
||||||
@ -209,14 +182,14 @@ Component.register('List', container => {
|
|||||||
}, Util.makeDom(/*html*/`
|
}, Util.makeDom(/*html*/`
|
||||||
<div class="list-group overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
|
<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>
|
<div class="vs-pad-top flex-shrink-0" style="height:0px;"></div>
|
||||||
<template slot-id="item" $each="this.state?._renderedList">
|
<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)">
|
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item d-inline-flex align-items-center pe-2 \${item.type==='group'?'bg-body-tertiary fw-bold ps-2':'list-group-item-action ' + (this.mode==='group'?'ps-4':'ps-2')} \${item.type==='group'?(this.state?.selectedGroup===item[this.groupidfield]?'active':''):(this.state?.selectedItem===item[this.idfield]?'active':'')}" $onclick="item.type==='group'?this.selectGroup(item,index):this.selectItem(item,index)">
|
||||||
<template $if="item.type === 'group'">
|
<template $if="item.type === 'group'">
|
||||||
<template $if="this.groupicon">
|
<template $if="this.groupicon">
|
||||||
<span $class="bi bi-\${this.groupicon} text-body"></span>
|
<span $class="bi bi-\${this.groupicon} text-body"></span>
|
||||||
</template>
|
</template>
|
||||||
<div class="flex-shrink-0 px-1" $text="\${item[this.grouplabelfield]}"></div>
|
<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>
|
<div slot-id="group-actions"></div>
|
||||||
</template>
|
</template>
|
||||||
<template $if="item.type === 'item'">
|
<template $if="item.type === 'item'">
|
||||||
@ -234,7 +207,7 @@ Component.register('List', container => {
|
|||||||
<span $class="bi bi-\${this.itemicon} text-body"></span>
|
<span $class="bi bi-\${this.itemicon} text-body"></span>
|
||||||
</template>
|
</template>
|
||||||
<div class="flex-shrink-0 px-1" $text="\${item[this.labelfield]}"></div>
|
<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>
|
<div slot-id="item-actions"></div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -116,7 +116,7 @@
|
|||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<AutoForm id="demoForm" vertical
|
<AutoForm id="demoForm" vertical
|
||||||
$.state.schema="window.formSchema"
|
$.state.schema="window.formSchema"
|
||||||
$state.data="State.formData"
|
$.state.data="State.formData"
|
||||||
$onsubmit="UI.toast('提交数据: ' + JSON.stringify(event.detail))">
|
$onsubmit="UI.toast('提交数据: ' + JSON.stringify(event.detail))">
|
||||||
</AutoForm>
|
</AutoForm>
|
||||||
</div>
|
</div>
|
||||||
@ -144,7 +144,7 @@
|
|||||||
<div style="height: 500px;" class="border rounded overflow-hidden">
|
<div style="height: 500px;" class="border rounded overflow-hidden">
|
||||||
<List id="demoList" fast collapsible auto-select class="h-100 overflow-auto"
|
<List id="demoList" fast collapsible auto-select class="h-100 overflow-auto"
|
||||||
$mode="State.listMode"
|
$mode="State.listMode"
|
||||||
$list="State.listData"
|
$.state.list="State.listData"
|
||||||
$.state.groups="State.listGroups"
|
$.state.groups="State.listGroups"
|
||||||
$onitemclick="UI.toast('点击项目: ' + event.detail.item.label)">
|
$onitemclick="UI.toast('点击项目: ' + event.detail.item.label)">
|
||||||
<template slot-id="item">
|
<template slot-id="item">
|
||||||
|
|||||||
108
test/form_test.html
Normal file
108
test/form_test.html
Normal 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>
|
||||||
277
test/lib/base.js
277
test/lib/base.js
File diff suppressed because one or more lines are too long
@ -3,12 +3,12 @@
|
|||||||
})(this, function(exports2) {
|
})(this, function(exports2) {
|
||||||
"use strict";
|
"use strict";
|
||||||
var _a;
|
var _a;
|
||||||
let _activeBinding = null;
|
let __activeBinding = null;
|
||||||
let _noWriteBack = null;
|
let __noWriteBack = null;
|
||||||
const setActiveBinding = (val) => _activeBinding = val;
|
const _setActiveBinding = (val) => __activeBinding = val;
|
||||||
const setNoWriteBack = (val) => _noWriteBack = val;
|
const _setNoWriteBack = (val) => __noWriteBack = val;
|
||||||
const _notifiers = /* @__PURE__ */ new Set();
|
const _notifiers = /* @__PURE__ */ new Set();
|
||||||
const onNotifyUpdate = (fn) => _notifiers.add(fn);
|
const _onNotifyUpdate = (fn) => _notifiers.add(fn);
|
||||||
function NewState(defaults = {}, getter = null, setter = null) {
|
function NewState(defaults = {}, getter = null, setter = null) {
|
||||||
const _defaults = {};
|
const _defaults = {};
|
||||||
const _stateMappings = /* @__PURE__ */ new Map();
|
const _stateMappings = /* @__PURE__ */ new Map();
|
||||||
@ -30,11 +30,11 @@
|
|||||||
if (key === "__watch") return _watchFunc;
|
if (key === "__watch") return _watchFunc;
|
||||||
if (key === "__unwatch") return _unwatchFunc;
|
if (key === "__unwatch") return _unwatchFunc;
|
||||||
if (key === "__isProxy") return true;
|
if (key === "__isProxy") return true;
|
||||||
if (_activeBinding) {
|
if (__activeBinding) {
|
||||||
if (!_stateMappings.has(key)) _stateMappings.set(key, /* @__PURE__ */ new Set());
|
if (!_stateMappings.has(key)) _stateMappings.set(key, /* @__PURE__ */ new Set());
|
||||||
_stateMappings.get(key).add(_activeBinding);
|
_stateMappings.get(key).add(__activeBinding);
|
||||||
if (!_activeBinding.node._states) _activeBinding.node._states = /* @__PURE__ */ new Set();
|
if (!__activeBinding.node._states) __activeBinding.node._states = /* @__PURE__ */ new Set();
|
||||||
_activeBinding.node._states.add(_stateMappings);
|
__activeBinding.node._states.add(_stateMappings);
|
||||||
}
|
}
|
||||||
return __getter(key);
|
return __getter(key);
|
||||||
},
|
},
|
||||||
@ -61,7 +61,7 @@
|
|||||||
bindings.delete(binding);
|
bindings.delete(binding);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (_noWriteBack !== binding.node) {
|
if (__noWriteBack !== binding.node) {
|
||||||
_notifiers.forEach((fn) => fn(binding));
|
_notifiers.forEach((fn) => fn(binding));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -111,7 +111,9 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
to.classList.add(...from.classList);
|
to.classList.add(...from.classList);
|
||||||
Array.from(from.childNodes).forEach((child) => to.appendChild(child));
|
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);
|
if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
|
||||||
}
|
}
|
||||||
function _makeComponent(name, node, scanObj, exists = {}) {
|
function _makeComponent(name, node, scanObj, exists = {}) {
|
||||||
@ -206,7 +208,7 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onNotifyUpdate((binding) => _updateBinding(binding));
|
_onNotifyUpdate((binding) => _updateBinding(binding));
|
||||||
function _clearRenderedNodes(node) {
|
function _clearRenderedNodes(node) {
|
||||||
if (node._renderedNodes) node._renderedNodes.forEach((nodes) => nodes.forEach((child) => {
|
if (node._renderedNodes) node._renderedNodes.forEach((nodes) => nodes.forEach((child) => {
|
||||||
child.remove();
|
child.remove();
|
||||||
@ -216,7 +218,7 @@
|
|||||||
function _updateBinding(binding) {
|
function _updateBinding(binding) {
|
||||||
const node = binding.node;
|
const node = binding.node;
|
||||||
if (!node.isConnected && node.tagName !== "TEMPLATE") return;
|
if (!node.isConnected && node.tagName !== "TEMPLATE") return;
|
||||||
setActiveBinding(binding);
|
_setActiveBinding(binding);
|
||||||
let result = binding.exp ? binding.tpl ? _returnCode(binding.tpl, { thisNode: node }, node._thisObj || node, node._ref || null) : null : binding.tpl;
|
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") {
|
if (binding.exp === 2 && typeof result === "string") {
|
||||||
try {
|
try {
|
||||||
@ -224,7 +226,7 @@
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setActiveBinding(null);
|
_setActiveBinding(null);
|
||||||
if (binding.prop) {
|
if (binding.prop) {
|
||||||
const prop = binding.prop;
|
const prop = binding.prop;
|
||||||
let o = node;
|
let o = node;
|
||||||
@ -235,10 +237,9 @@
|
|||||||
if (typeof o !== "object") break;
|
if (typeof o !== "object") break;
|
||||||
}
|
}
|
||||||
if (typeof o === "object" && o !== null) {
|
if (typeof o === "object" && o !== null) {
|
||||||
const resultIsObject = typeof result === "object" && result != null && !Array.isArray(result);
|
|
||||||
const lk = prop[prop.length - 1];
|
const lk = prop[prop.length - 1];
|
||||||
if (lk) {
|
if (lk) {
|
||||||
if (resultIsObject && o[lk] == null) o[lk] = {};
|
if (typeof result === "object" && result != null && !Array.isArray(result) && o[lk] == null) o[lk] = {};
|
||||||
const lo = o[lk];
|
const lo = o[lk];
|
||||||
if (typeof lo === "object" && lo != null && lo.__watch) Object.assign(lo, result);
|
if (typeof lo === "object" && lo != null && lo.__watch) Object.assign(lo, result);
|
||||||
else {
|
else {
|
||||||
@ -419,12 +420,12 @@
|
|||||||
if (realAttrName === "bind") {
|
if (realAttrName === "bind") {
|
||||||
node.addEventListener(["textarea", "text", "password"].includes(node.type || "text") || node.isContentEditable ? "input" : "change", (e) => {
|
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;
|
let newVal = node.isContentEditable ? e.target.innerHTML : node.type === "checkbox" ? e.target.checked : e.target.files || e.target.value || e.detail;
|
||||||
setNoWriteBack(node);
|
_setNoWriteBack(node);
|
||||||
setDisableRunCodeError(true);
|
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 || {});
|
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 || {});
|
else _runCode(`${tpl} = val`, { val: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
|
||||||
setDisableRunCodeError(false);
|
setDisableRunCodeError(false);
|
||||||
setNoWriteBack(null);
|
_setNoWriteBack(null);
|
||||||
});
|
});
|
||||||
} else if (realAttrName === "text" && !tpl) {
|
} else if (realAttrName === "text" && !tpl) {
|
||||||
tpl = node.textContent;
|
tpl = node.textContent;
|
||||||
@ -441,6 +442,13 @@
|
|||||||
if (node._thisObj) scanObj.thisObj = node._thisObj;
|
if (node._thisObj) scanObj.thisObj = node._thisObj;
|
||||||
};
|
};
|
||||||
const _scanTree = (node, scanObj = {}) => {
|
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.nodeType !== 1) return;
|
||||||
if (!node._stTranslated) {
|
if (!node._stTranslated) {
|
||||||
Array.from(node.attributes).forEach((attr) => {
|
Array.from(node.attributes).forEach((attr) => {
|
||||||
@ -451,6 +459,18 @@
|
|||||||
});
|
});
|
||||||
node._stTranslated = true;
|
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"))) {
|
if (node.tagName === "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("st-if")) && (node.hasAttribute("$each") || node.hasAttribute("st-each"))) {
|
||||||
const template = document.createElement("TEMPLATE");
|
const template = document.createElement("TEMPLATE");
|
||||||
const attrs = Array.from(node.attributes).filter((attr2) => ["$if", "$each", "st-if", "st-each"].includes(attr2.name));
|
const attrs = Array.from(node.attributes).filter((attr2) => ["$if", "$each", "st-if", "st-each"].includes(attr2.name));
|
||||||
@ -508,7 +528,7 @@
|
|||||||
});
|
});
|
||||||
node.childNodes && node.childNodes.forEach((child) => _unbindTree(child));
|
node.childNodes && node.childNodes.forEach((child) => _unbindTree(child));
|
||||||
};
|
};
|
||||||
const ____RefreshState_Internal_Force_Full_Scan_Only_In_Extreme_Performance_Scenarios = _scanTree;
|
const _unsafeRefreshState = _scanTree;
|
||||||
const Util = {
|
const Util = {
|
||||||
clone: window.structuredClone || ((obj) => JSON.parse(JSON.stringify(obj))),
|
clone: window.structuredClone || ((obj) => JSON.parse(JSON.stringify(obj))),
|
||||||
base64: (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
|
base64: (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
|
||||||
@ -603,18 +623,14 @@
|
|||||||
Component,
|
Component,
|
||||||
$,
|
$,
|
||||||
$$,
|
$$,
|
||||||
____RefreshState_Internal_Force_Full_Scan_Only_In_Extreme_Performance_Scenarios,
|
RefreshState: _unsafeRefreshState,
|
||||||
SetTranslator,
|
SetTranslator,
|
||||||
_scanTree,
|
_scanTree,
|
||||||
_unbindTree,
|
_unbindTree,
|
||||||
Util,
|
Util,
|
||||||
Hash,
|
Hash,
|
||||||
LocalStorage,
|
LocalStorage,
|
||||||
State,
|
State
|
||||||
_runCode,
|
|
||||||
_returnCode,
|
|
||||||
onNotifyUpdate,
|
|
||||||
setActiveBinding
|
|
||||||
};
|
};
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.ApigoState = ApigoState;
|
window.ApigoState = ApigoState;
|
||||||
@ -645,10 +661,10 @@
|
|||||||
exports2.Hash = Hash;
|
exports2.Hash = Hash;
|
||||||
exports2.LocalStorage = LocalStorage;
|
exports2.LocalStorage = LocalStorage;
|
||||||
exports2.NewState = NewState;
|
exports2.NewState = NewState;
|
||||||
|
exports2.RefreshState = _unsafeRefreshState;
|
||||||
exports2.SetTranslator = SetTranslator;
|
exports2.SetTranslator = SetTranslator;
|
||||||
exports2.State = State;
|
exports2.State = State;
|
||||||
exports2.Util = Util;
|
exports2.Util = Util;
|
||||||
exports2.____RefreshState_Internal_Force_Full_Scan_Only_In_Extreme_Performance_Scenarios = ____RefreshState_Internal_Force_Full_Scan_Only_In_Extreme_Performance_Scenarios;
|
|
||||||
exports2._scanTree = _scanTree;
|
exports2._scanTree = _scanTree;
|
||||||
exports2._unbindTree = _unbindTree;
|
exports2._unbindTree = _unbindTree;
|
||||||
Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
|
Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
|
||||||
|
|||||||
100
test/list_test.html
Normal file
100
test/list_test.html
Normal 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
59
test/mega_verify.spec.js
Normal 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);
|
||||||
|
});
|
||||||
34
test/verify_stress.spec.js
Normal file
34
test/verify_stress.spec.js
Normal 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
20
test/verify_style.spec.js
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
lib: {
|
lib: {
|
||||||
entry: resolve(__dirname, 'src/index.js'),
|
entry: resolve(__dirname, 'src/index.js'),
|
||||||
name: 'ApigoBase',
|
name: 'ApigoBase',
|
||||||
formats: ['umd', 'es']
|
formats: ['umd']
|
||||||
},
|
},
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: ['@apigo.cc/state', '@apigo.cc/bootstrap'],
|
external: ['@apigo.cc/state', '@apigo.cc/bootstrap'],
|
||||||
@ -42,15 +42,6 @@ export default defineConfig({
|
|||||||
'@apigo.cc/bootstrap': 'bootstrap'
|
'@apigo.cc/bootstrap': 'bootstrap'
|
||||||
},
|
},
|
||||||
plugins: [terser()]
|
plugins: [terser()]
|
||||||
},
|
|
||||||
{
|
|
||||||
format: 'es',
|
|
||||||
entryFileNames: 'base.mjs'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
format: 'es',
|
|
||||||
entryFileNames: 'base.min.mjs',
|
|
||||||
plugins: [terser()]
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user