feat(ui): 增强数据管理与自动化测试(by AI)

This commit is contained in:
Star 2026-07-13 22:24:23 +08:00
parent e60e5155c4
commit 9cb6ba1cb7
32 changed files with 1029 additions and 670 deletions

10
CHANGELOG.md Normal file
View File

@ -0,0 +1,10 @@
# Changelog
## [v1.0.1] - 2026-07-13
- **新增**:
- DataGrid 支持数据 API 自动加载、安全 filter/sort 查询、服务端保存和高级系统字段模式。
- AutoForm 支持数据库字段描述、只读 label 与 divider 类型。
- API 根据请求 data 自动选择 POSTNav 明确动作事件语义。
- **修复**:
- Select 初始值、异步 DataGrid 数据绑定与 Modal 长内容滚动。

View File

@ -7,13 +7,13 @@
Pages load `ui.js` directly from a CDN and explicitly declare required public APIs:
```html
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="base,form"></script>
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.1/ui.js" components="base,form"></script>
```
`ui.js` always loads every file in `frameworks/` and `utilities/`. The `components` attribute accepts public component names and the `base` and `form` categories; both forms can be mixed.
Load `ui.js` in `<head>` as a normal script. Do not use `async`, `defer`, or `type="module"`: it writes the requested classic scripts into the parser stream so state can scan the complete DOM at `DOMContentLoaded` before first render.
`npm run build` does not bundle source files or create a `dist` directory. It updates the release version, writes source-file cache versions into `ui.js`, and produces adjacent `.min.js` files. Loading `ui.min.js` automatically loads the matching minified framework, utility, and component files.
`npm run build` does not bundle source files or create a `dist` directory. It first copies `../state/dist/state.js` and `../bootstrap/dist/bootstrap.js` into `frameworks/`, then writes their source-file modification-time cache versions into `ui.js` and produces adjacent `.min.js` files. Loading `ui.min.js` automatically loads the matching minified framework, utility, and component files. The UI package version remains independent of its framework packages.
For AI-oriented API documentation, start with [AI.yaml](AI.yaml).

View File

@ -5,7 +5,7 @@
`@apigo.cc/ui` 是不需要生产构建的原生 JavaScript UI 组件库。页面使用一个 `ui.js` 入口,并且每个页面都显式写出使用的组件:
```html
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="base,form"></script>
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.1/ui.js" components="base,form"></script>
```
`ui.js` 默认加载 `frameworks/` 和底层 `utilities/``components` 中只填写页面直接使用的公开组件名,例如 `components="List,AutoForm,Resizer"`;组件依赖会自动加载,不需要填写 state、bootstrap、HTTP、VirtualScroll 或 MouseMover。
@ -14,4 +14,4 @@
AI 使用说明从 [AI.yaml](AI.yaml) 开始;文档与源码同目录,组件示例直接写在对应 YAML 中。
`npm run build` 不会打包源码,也不会生成 `dist`。它会更新发布版本、把各源码文件的修改时间版本写入 `ui.js`,并在源码旁生成 `.min.js`。加载 `ui.min.js` 时,入口会自动改为加载对应的 framework、utility 和 component 的 `.min.js`
`npm run build` 不会打包源码,也不会生成 `dist`。它会先将 `../state/dist/state.js``../bootstrap/dist/bootstrap.js` 复制到 `frameworks/`,再把所有源码文件的最后修改时间缓存版本写入 `ui.js`,并在源码旁生成 `.min.js`。加载 `ui.min.js` 时,入口会自动改为加载对应的 framework、utility 和 component 的 `.min.js`UI 包的版本独立于前置框架包。

View File

@ -8,7 +8,7 @@ const APIComponent = globalThis.Component.register('API', container => {
container.request = globalThis.NewState({
url: '',
method: 'GET',
method: '',
headers: {},
data: null,
timeout: 10000,
@ -32,6 +32,7 @@ const APIComponent = globalThis.Component.register('API', container => {
return new Promise((resolve, reject) => {
const req = { ...container.request, ...opt }
if (!req.url) throw new Error('.url is required')
if (!req.method) req.method = req.data == null ? 'GET' : 'POST'
req.headers = { ...container.request.headers, ...opt.headers }
container.response.loading = true
globalThis.HTTP.request(req).then(resp => {

View File

@ -1 +1 @@
const APIComponent=globalThis.Component.register("API",e=>{const r=e.request&&"object"==typeof e.request?e.request:{},s=e.response&&"object"==typeof e.response?e.response:{},t=e.result&&"object"==typeof e.result?e.result:{};e.request=globalThis.NewState({url:"",method:"GET",headers:{},data:null,timeout:1e4,responseType:"",...r,headers:{...r.headers||{}}}),e.response=globalThis.NewState({loading:!1,ok:null,status:null,error:null,headers:{},responseType:"",result:null,...s,headers:{...s.headers||{}}}),e.result=globalThis.NewState(t),e.do=(r={})=>new Promise((s,t)=>{const o={...e.request,...r};if(!o.url)throw new Error(".url is required");o.headers={...e.request.headers,...r.headers},e.response.loading=!0,globalThis.HTTP.request(o).then(r=>{if(Object.keys(r).forEach(s=>{"result"!==s&&(e.response[s]=r[s])}),r.result&&"object"==typeof r.result&&e.result&&"object"==typeof e.result?Object.assign(e.result,r.result):e.result=r.result,e.response.loading=!1,!1===r.ok)throw new Error(r.error);if("object"==typeof r.result&&r.result.error)throw new Error(r.result.error);e.dispatchEvent(new CustomEvent("response",{detail:r,bubbles:!1})),s(r)}).catch(s=>{r.noui||globalThis.Toast?.show(s.message,{type:"danger"}),e.dispatchEvent(new CustomEvent("error",{detail:s,bubbles:!0})),t(s)})});let o=null;e.request.__watch(null,()=>{e.hasAttribute("auto")&&e.request.url&&(o||(o=Promise.resolve().then(()=>{e.do(),o=null})))})});
const APIComponent=globalThis.Component.register("API",e=>{const r=e.request&&"object"==typeof e.request?e.request:{},s=e.response&&"object"==typeof e.response?e.response:{},t=e.result&&"object"==typeof e.result?e.result:{};e.request=globalThis.NewState({url:"",method:"",headers:{},data:null,timeout:1e4,responseType:"",...r,headers:{...r.headers||{}}}),e.response=globalThis.NewState({loading:!1,ok:null,status:null,error:null,headers:{},responseType:"",result:null,...s,headers:{...s.headers||{}}}),e.result=globalThis.NewState(t),e.do=(r={})=>new Promise((s,t)=>{const o={...e.request,...r};if(!o.url)throw new Error(".url is required");o.method||(o.method=null==o.data?"GET":"POST"),o.headers={...e.request.headers,...r.headers},e.response.loading=!0,globalThis.HTTP.request(o).then(r=>{if(Object.keys(r).forEach(s=>{"result"!==s&&(e.response[s]=r[s])}),r.result&&"object"==typeof r.result&&e.result&&"object"==typeof e.result?Object.assign(e.result,r.result):e.result=r.result,e.response.loading=!1,!1===r.ok)throw new Error(r.error);if("object"==typeof r.result&&r.result.error)throw new Error(r.result.error);e.dispatchEvent(new CustomEvent("response",{detail:r,bubbles:!1})),s(r)}).catch(s=>{r.noui||globalThis.Toast?.show(s.message,{type:"danger"}),e.dispatchEvent(new CustomEvent("error",{detail:s,bubbles:!0})),t(s)})});let o=null;e.request.__watch(null,()=>{e.hasAttribute("auto")&&e.request.url&&(o||(o=Promise.resolve().then(()=>{e.do(),o=null})))})});

View File

@ -19,27 +19,28 @@
const tick = () => new Promise(resolve => setTimeout(resolve, 80))
const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
window.runTest = async () => {
const testResult = { name: 'components.API', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: ['auto', 'request', 'response', 'result', 'do', 'response-event', 'error-event', 'noui'], percent: 0 } }
const testResult = { name: 'components.API', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], coverage: { tested: [], total: ['auto', 'request', 'response', 'result', 'do', 'post-default', 'response-event', 'error-event', 'noui'], percent: 0 } }
await tick()
let autoResponses = 0; autoApi.addEventListener('response', () => { autoResponses++ })
autoApi.request.url = '/__http__/json?next=1'; await tick()
assert(testResult, 'auto', autoApi.response.ok === true && autoApi.result.method === 'GET' && autoResponses === 1, 'auto requests again after request changes')
assert(testResult, 'request', manualApi.request.url === '' && manualApi.request.method === 'GET' && manualApi.request.data === null && manualApi.request.timeout === 10000 && manualApi.request.responseType === '' && typeof manualApi.request.headers === 'object', 'request exposes documented defaults')
assert(testResult, 'request', manualApi.request.url === '' && manualApi.request.method === '' && manualApi.request.data === null && manualApi.request.timeout === 10000 && manualApi.request.responseType === '' && typeof manualApi.request.headers === 'object', 'request exposes documented defaults')
assert(testResult, 'response', autoApi.response.loading === false && autoApi.response.status === 200 && autoApi.response.responseType === 'json' && typeof autoApi.response.headers === 'object', 'response exposes loading, status, headers, and responseType')
assert(testResult, 'result-binding', document.querySelector('#autoResult').textContent === 'GET', 'response result is usable in declarative DOM bindings')
let responseEvent
manualApi.addEventListener('response', event => { responseEvent = event.detail }, { once: true })
const response = await manualApi.do(successRequest)
assert(testResult, 'do', response.ok && manualApi.result.method === 'GET', 'do() sends a request and updates result')
const postResponse = await manualApi.do({ url: '/__http__/json', data: { action: 'tables' } }); assert(testResult, 'post-default', postResponse.ok && manualApi.result.method === 'POST', 'data defaults an unspecified method to POST')
assert(testResult, 'response-event', responseEvent?.status === 200, 'response event exposes the complete HTTP response')
assert(testResult, 'result', manualApi.result.method === 'GET', 'result exposes the latest response result')
assert(testResult, 'result', manualApi.result.method === 'POST', 'result exposes the latest response result')
let failed = false; let errorEvent
manualApi.addEventListener('error', event => { errorEvent = event.detail }, { once: true })
try { await manualApi.do({ ...errorRequest, noui: true }) } catch (error) { failed = true }
assert(testResult, 'error', failed && manualApi.response.status === 200 && manualApi.response.ok === true && manualApi.result.error === 'application failed', 'application error rejects and preserves response state')
assert(testResult, 'error-event', errorEvent instanceof Error && errorEvent.message === 'application failed', 'error event exposes Error detail')
assert(testResult, 'noui', !document.querySelector('Toast'), 'noui suppresses automatic error Toast')
testResult.coverage.tested = ['auto', 'request', 'response', 'result', 'do', 'response-event', 'error-event', 'noui']; testResult.coverage.percent = 100; testResult.status = testResult.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult
testResult.coverage.tested = ['auto', 'request', 'response', 'result', 'do', 'post-default', 'response-event', 'error-event', 'noui']; testResult.coverage.percent = 100; testResult.status = testResult.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult
}
document.querySelector('#runAll').onclick = window.runTest
</script>

View File

@ -9,7 +9,7 @@ properties:
request:
default:
url: ''
method: GET
method: "GET when data is null; POST when data is present, unless explicitly set."
headers: {}
data: null
timeout: 10000
@ -34,6 +34,7 @@ events:
rules:
- request.url is required before calling do.
- Without request.method, API uses POST when request.data is present and GET otherwise.
- Bind request with $.request when external state owns request configuration.
examples:
@ -47,6 +48,9 @@ examples:
<script>
const response = await usersApi.do({ url: '/api/users', method: 'GET', noui: true })
if (response.ok) console.log(response.result)
post_default: |
<API auto .request.url="/admin/table" .request.data.action="tables"></API>
<!-- data is present, so this request uses POST unless method is explicitly set -->
</script>
tests:

View File

@ -6,8 +6,12 @@ const AUTOFORM_BLUEPRINT = globalThis.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 || []">
<template $each="this.state._schema || []">
<template $$if="item.if || 'true'">
<template $if="item.type === 'divider'">
<div style="grid-column:1/-1" class="pt-2 pb-1"><hr class="my-2"><div $if="item.label" $text="item.label" class="small fw-semibold text-muted text-uppercase"></div></div>
</template>
<template $if="item.type !== 'divider'">
<div style="display:contents">
<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'}">
@ -30,9 +34,12 @@ const AUTOFORM_BLUEPRINT = globalThis.Util.makeDom(/*html*/`
</div></template>
<template $if="item.type === 'textarea'"><textarea $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.state.data[item.name]"></textarea></template>
<template $if="item.type === 'label'"><span $name="item.name" $.="item.setting || {}" $text="item.value ?? this.state.data[item.name] ?? ''" class="form-control-plaintext text-break py-1"></span></template>
</div>
</div>
</template>
</template>
</template>
<div $class="\${this.inline ? '' : 'd-flex justify-content-end align-items-baseline gap-3 mt-2'}" $style="\${this.inline ? '' : 'grid-column:1/-1'}">
@ -61,8 +68,36 @@ const AUTOFORM_STYLE = globalThis.Util.makeDom(/*html*/`<style>
.auto-form-root .form-switch .form-check-input { width: 2em; }
</style>`)
const DB_FORM_TYPE_MAP = {
i: 'number', ui: 'number', bi: 'number', ubi: 'number', ti: 'number', f: 'number', ff: 'number',
b: 'switch', d: 'date', dt: 'datetime', t: 'textarea'
};
const getDbFormType = type => DB_FORM_TYPE_MAP[type] || 'text';
// Database field definitions are converted only for rendering. Form data keeps
// its original keys, so submitting an AutoForm still returns the API row shape.
const normalizeSchemaItem = field => {
if (!field?.tableID) return field;
const settings = field.settings || {};
return {
...field,
name: field.name,
label: settings.label ?? field.label ?? field.name,
type: settings.type ?? getDbFormType(field.type),
options: settings.options ?? field.options,
placeholder: settings.placeholder ?? field.placeholder,
setting: settings.attrs ?? field.setting
};
};
globalThis.Component.register('AutoForm', container => {
if (!container.state.schema) container.state.schema = []
if (!Array.isArray(container.state.schema)) container.state.schema = []
const setSchema = schema => {
container.state._schema = Array.isArray(schema) ? schema.map(normalizeSchemaItem) : [];
};
container.state.__watch('schema', setSchema)
setSchema(container.state.schema)
const ensureProxy = v => (v && typeof v === 'object' && !v.__isProxy) ? globalThis.NewState(v) : v;
const setData = value => {

File diff suppressed because one or more lines are too long

View File

@ -31,6 +31,10 @@
{ name: 'hidden', label: 'Hidden', type: 'text', if: "this.data.role === 'user'" }
]
const compactSchema = [{ name: 'name', label: 'Name', type: 'text' }]
const databaseFields = [
{ id: 'f-name', tableID: 'users', name: 'name', type: 'v100', settings: { type: 'text', attrs: { required: true } } },
{ id: 'f-role', tableID: 'users', name: 'role', type: 'v30', settings: { type: 'select', options: ['admin', 'user'] } }
]
const saveRequest = { url: '/__http__/json', method: 'POST' }
const errorRequest = { url: '/__http__/api-error', method: 'POST' }
</script>
@ -45,11 +49,12 @@
<AutoForm id="saveForm" nobutton $.api="saveApi" $.state.data="profile" $.state.schema="compactSchema"></AutoForm>
<AutoForm id="errorForm" nobutton $.api="errorApi" $.state.data="profile" $.state.schema="compactSchema"></AutoForm>
<AutoForm id="cancelForm" nobutton $.api="saveApi" $.state.data="profile" $.state.schema="compactSchema"></AutoForm>
<AutoForm id="databaseForm" nobutton $.state.data="profile" $.state.schema="databaseFields"></AutoForm>
<button id="runAll" type="button">测试全部</button>
<pre id="result"></pre>
<script>
const tick = () => new Promise(resolve => setTimeout(resolve, 80))
const cases = ['data', 'all-controls', 'setting', 'options', 'if', 'custom-type', 'vertical', 'horizontal', 'inline', 'nobutton', 'submitlabel', 'slot', 'submit', 'submit-event', 'response-event', 'error-event']
const cases = ['data', 'all-controls', 'setting', 'options', 'database-schema', 'if', 'custom-type', 'vertical', 'horizontal', 'inline', 'nobutton', 'submitlabel', 'slot', 'submit', 'submit-event', 'response-event', 'error-event']
const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
window.runTest = async () => {
const testResult = { name: 'components.form.AutoForm', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }
@ -61,6 +66,7 @@
assert(testResult, 'all-controls', form.querySelectorAll('[control-wrapper]').length === 16 && standardTypes.every(type => form.querySelector(`[name="${profileSchema.find(item => item.type === type).name}"]`)) && customTypes.every(type => form.querySelector(type)), 'one schema renders every documented standard and custom control type')
assert(testResult, 'setting', form.querySelector('input[name="name"]').required, 'setting attributes are copied to controls')
assert(testResult, 'options', form.querySelectorAll('[name="role"] option').length === 2, 'select options render schema options')
const databaseForm = document.querySelector('#databaseForm'); assert(testResult, 'database-schema', databaseForm.querySelector('input[name="name"]')?.required && databaseForm.querySelectorAll('select[name="role"] option').length === 2, 'database field definitions are converted to a plain form schema without changing form data keys')
assert(testResult, 'if', !form.querySelector('input[name="hidden"]'), '$$if hides fields whose expression is false')
form.data.role = 'user'; await tick(); assert(testResult, 'if', !!form.querySelector('input[name="hidden"]'), '$$if evaluates item.if again after form data changes'); form.data.role = 'admin'
assert(testResult, 'custom-type', !!form.querySelector('TagsInput'), 'registered AutoForm component type renders its component')

View File

@ -20,10 +20,13 @@ properties:
schema:
item: "{name, type, label?, setting?, options?, placeholder?, if?}"
types: "text|password|email|number|date|datetime|file|select|checkbox|radio|switch|textarea|DatePicker|ColorPicker|IconPicker|TagsInput"
types: "text|password|email|number|date|datetime|file|select|checkbox|radio|switch|textarea|label|divider|DatePicker|ColorPicker|IconPicker|TagsInput"
options: "select/checkbox/radio options: ['A', {value:'b', label:'B'}]"
setting: "attributes copied to the control; DatePicker rangeEnd is an example"
if: 'JavaScript expression evaluated by `$$if`; use component data through `this.data`, for example `this.data.role === "admin"`.'
label: Read-only text displaying item.value or data[item.name].
divider: Full-width section divider; label is its optional section title.
database_fields: 'Data-API field definitions with tableID are accepted and converted for rendering only: name stays the data key; settings.type is the control type; settings.options are control options; settings.attrs become control attributes. The original fields and data are not changed.'
submit:
source: Set api to an API element, or set request.url.
@ -42,6 +45,10 @@ examples:
]
</script>
<AutoForm $.state.data="profile" $.state.schema="profileSchema"></AutoForm>
database_fields: |
const fields = [{ id: 'f-role', tableID: 'users', name: 'Role', type: 'v30', settings: { type: 'select', options: ['Admin', 'User'] } }]
const user = { Role: 'Admin' }
<AutoForm $.state.data="user" $.state.schema="fields"></AutoForm>
submit_api: |
<script>
const profile = { name: 'Ada' }

View File

@ -15,7 +15,7 @@ globalThis.Component.register('Modal', container => {
container.hide = (...args) => container.modal.hide(...args)
}, globalThis.Util.makeDom(/*html*/`
<div class="modal fade" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">
<div $class="modal-content border-\${this.state?.type || 'primary'} border-2 shadow-lg">
<div slot-id="header" class="modal-header py-2 px-3 bg-light">
<h6 $class="modal-title fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></h6>

View File

@ -1 +1 @@
globalThis.Component.register("Modal",t=>{t.modal=new bootstrap.Modal(t),t.addEventListener("bind",d=>{d.detail?t.modal.show():t.modal.hide()}),t.addEventListener("hide.bs.modal",()=>{document.activeElement?.blur(),t.dispatchEvent(new CustomEvent("change",{bubbles:!1,detail:!1}))}),t.show=(...d)=>t.modal.show(...d),t.hide=(...d)=>t.modal.hide(...d)},globalThis.Util.makeDom('\n<div class="modal fade" data-bs-backdrop="static">\n <div class="modal-dialog modal-dialog-centered">\n <div $class="modal-content border-${this.state?.type || \'primary\'} border-2 shadow-lg">\n <div slot-id="header" class="modal-header py-2 px-3 bg-light">\n <h6 $class="modal-title fw-bold text-${this.state?.type || \'primary\'}" $text="this.state?.title"></h6>\n <button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset p-0" style="color:inherit; text-decoration:none" data-bs-dismiss="modal"></button>\n </div>\n <div slot-id="body" class="modal-body p-3"></div>\n <div slot-id="footer" class="modal-footer py-2 px-3 bg-light"></div>\n </div>\n </div>\n</div>\n'));
globalThis.Component.register("Modal",t=>{t.modal=new bootstrap.Modal(t),t.addEventListener("bind",d=>{d.detail?t.modal.show():t.modal.hide()}),t.addEventListener("hide.bs.modal",()=>{document.activeElement?.blur(),t.dispatchEvent(new CustomEvent("change",{bubbles:!1,detail:!1}))}),t.show=(...d)=>t.modal.show(...d),t.hide=(...d)=>t.modal.hide(...d)},globalThis.Util.makeDom('\n<div class="modal fade" data-bs-backdrop="static">\n <div class="modal-dialog modal-dialog-centered modal-dialog-scrollable">\n <div $class="modal-content border-${this.state?.type || \'primary\'} border-2 shadow-lg">\n <div slot-id="header" class="modal-header py-2 px-3 bg-light">\n <h6 $class="modal-title fw-bold text-${this.state?.type || \'primary\'}" $text="this.state?.title"></h6>\n <button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset p-0" style="color:inherit; text-decoration:none" data-bs-dismiss="modal"></button>\n </div>\n <div slot-id="body" class="modal-body p-3"></div>\n <div slot-id="footer" class="modal-footer py-2 px-3 bg-light"></div>\n </div>\n </div>\n</div>\n'));

View File

@ -17,7 +17,7 @@
<pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mt-3 mb-0 flex-shrink-0 small"></pre>
</div>
<script>
const cases = ['binding', 'title', 'type', 'show', 'hide', 'change-event', 'header-slot', 'body-slot', 'footer-slot']
const cases = ['binding', 'title', 'type', 'show', 'hide', 'change-event', 'header-slot', 'body-slot', 'footer-slot', 'body-scroll']
const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event)))
const tick = () => new Promise(resolve => setTimeout(resolve, 500)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
window.runTest = async () => { errors.length = 0; const r = { name: 'components.Modal', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }; const modal = document.querySelector('#editModal'); let change
@ -29,6 +29,7 @@
assert(r, 'header-slot', modal.querySelector('#customHeader')?.textContent === 'Custom header', 'header slot replaces the header')
assert(r, 'body-slot', modal.querySelector('#customBody')?.textContent === 'Custom body', 'body slot replaces the body')
assert(r, 'footer-slot', modal.querySelector('#customFooter')?.textContent === 'Custom footer', 'footer slot replaces the footer')
assert(r, 'body-scroll', modal.querySelector('.modal-dialog')?.classList.contains('modal-dialog-scrollable'), 'long body content scrolls without moving the header or footer')
modal.hide(); await tick(); assert(r, 'hide', !modal.classList.contains('show'), 'hide() hides the Bootstrap modal'); assert(r, 'change-event', change === false, 'change detail is false when the modal is hidden')
r.coverage.tested = [...cases]; r.coverage.percent = 100; r.consoleErrors = [...errors]; r.status = r.failed || r.consoleErrors.length ? 'failed' : 'passed'; document.querySelector('#status').className = 'badge text-bg-' + (r.status === 'passed' ? 'success' : 'danger'); document.querySelector('#status').textContent = r.status === 'passed' ? '通过' : '失败'; document.querySelector('#coverage').textContent = '覆盖 100%'; document.querySelector('#result').textContent = JSON.stringify(r, null, 2); return r }
document.querySelector('#runAll').onclick = window.runTest

View File

@ -3,6 +3,9 @@ name: Modal
binding:
behavior: Bind a boolean with $bind to show or hide the modal.
layout:
behavior: Long body content scrolls inside the modal; the header and footer remain visible.
state:
title: Default header title.
type: Bootstrap contextual type. Default is primary.

View File

@ -11,20 +11,21 @@ globalThis.Component.register('Nav', container => {
})
container.click = (item, noselect) => {
if (!item.noselect && !noselect) {
const selected = !item.noselect && !noselect
if (selected) {
container.state.value = item.name
container.dispatchEvent(new CustomEvent('change', { detail: item.name, bubbles: false }))
}
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
container.dispatchEvent(new CustomEvent('nav', { detail: { item, name: item.name, selected }, bubbles: false }))
}
container.clickSubitem = item => {
container.state.value = item.name
container.dispatchEvent(new CustomEvent('change', { detail: item.name, bubbles: false }))
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
container.dispatchEvent(new CustomEvent('nav', { detail: { item, name: item.name, selected: true }, bubbles: false }))
}
container.toggleGroup = item => {
container.state.openName = container.state.openName === item.name ? null : item.name
container.dispatchEvent(new CustomEvent('nav', { detail: { item, open: container.state.openName === item.name }, bubbles: false }))
container.dispatchEvent(new CustomEvent('nav', { detail: { item, name: item.name, open: container.state.openName === item.name, selected: false }, bubbles: false }))
}
}, globalThis.Util.makeDom(/*html*/`
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-1'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
@ -41,7 +42,7 @@ globalThis.Component.register('Nav', container => {
</div>
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
<template $each="this.state?.list || []">
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}" style="transform: translateY(3px);">
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}">
<template $if="item.type==='label'">
<div $class="\${this.vertical ? 'small text-uppercase text-body-secondary fw-semibold px-2 py-1 mt-2' : 'navbar-text small text-uppercase text-body-secondary fw-semibold px-2'} \${item.class || ''}" $text="item.label"></div>
</template>
@ -86,7 +87,7 @@ globalThis.Component.register('Nav', container => {
<button type="button" $class="nav-link dropdown-toggle \${item.class || ''}" 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-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px;'">
<div class="dropdown-menu dropdown-menu-end p-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px; z-index: 1080;'">
<template $each="item.items || item.list" as="subitem">
<template $if="subitem.type==='label'">
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>

File diff suppressed because one or more lines are too long

View File

@ -5,14 +5,14 @@
<title>components.Nav tests</title><script src="../../ui.js" components="Nav"></script>
<script>
const navBrand = { icon: 'box', label: 'Apigo' }
const navItems = [{ type: 'label', label: 'Main' }, { type: 'button', name: 'home', label: 'Home', icon: 'house' }, { type: 'dropdown', name: 'admin', label: 'Admin', icon: 'shield', items: [{ type: 'label', label: 'Manage' }, { type: 'button', name: 'roles', label: 'Roles', icon: 'person' }, { type: 'switch', name: 'alerts', label: 'Alerts', bind: { alerts: false } }] }, { type: 'fill' }, { type: 'button', name: 'settings', label: 'Settings', icon: 'gear' }]
const navItems = [{ type: 'label', label: 'Main' }, { type: 'button', name: 'home', label: 'Home', icon: 'house' }, { type: 'dropdown', name: 'admin', label: 'Admin', icon: 'shield', items: [{ type: 'label', label: 'Manage' }, { type: 'button', name: 'roles', label: 'Roles', icon: 'person' }, { type: 'switch', name: 'alerts', label: 'Alerts', bind: { alerts: false } }] }, { type: 'fill' }, { type: 'button', name: 'settings', label: 'Settings', icon: 'gear' }, { type: 'button', name: 'logout', label: 'Logout', icon: 'door-open', noselect: true }]
</script>
</head>
<body class="vh-100 overflow-hidden bg-body-tertiary"><div class="container-fluid h-100 py-3 d-flex flex-column">
<header class="d-flex align-items-center gap-2 mb-3 flex-shrink-0"><h1 class="h4 mb-0">components.Nav</h1><span id="status" class="badge text-bg-secondary">未测试</span><button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button><span id="coverage" class="small text-body-secondary"></span></header>
<main class="flex-fill overflow-auto"><Nav id="horizontalNav" $.state.brand="navBrand" $.state.list="navItems" $bind="Hash.nav"></Nav><div class="mt-3 border" style="height:220px"><Nav id="verticalNav" vertical $.state.brand="navBrand" $.state.list="navItems" $bind="Hash.nav"></Nav></div></main><pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mt-3 mb-0 flex-shrink-0 small"></pre>
</div><script>
const cases = ['vertical', 'brand', 'list', 'value', 'Hash.nav', 'openName', 'label', 'button', 'dropdown', 'fill', 'change-event', 'nav-event', 'item-shape']; const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event))); const tick = () => new Promise(resolve => setTimeout(resolve, 60)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
const cases = ['vertical', 'brand', 'list', 'value', 'Hash.nav', 'openName', 'label', 'button', 'dropdown', 'fill', 'change-event', 'nav-event', 'noselect-nav', 'item-shape']; const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event))); const tick = () => new Promise(resolve => setTimeout(resolve, 60)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
window.runTest = async () => { errors.length = 0; const r = { name: 'components.Nav', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }; await tick(); const horizontal = document.querySelector('#horizontalNav'); const vertical = document.querySelector('#verticalNav'); const navLink = (nav, label) => [...nav.querySelectorAll('.nav-link')].find(node => node.textContent.includes(label)); let change; const navEvents = []; horizontal.addEventListener('change', event => { change = event.detail }); horizontal.addEventListener('nav', event => navEvents.push(event.detail)); vertical.addEventListener('nav', event => navEvents.push(event.detail))
assert(r, 'vertical', vertical.vertical === true && vertical.querySelector('.flex-column'), 'vertical attribute enables vertical navigation')
assert(r, 'brand', horizontal.textContent.includes('Apigo') && horizontal.querySelector('.bi-box') && vertical.textContent.includes('Apigo'), 'brand renders the same icon and label in both layouts')
@ -23,5 +23,6 @@
navLink(vertical, 'Admin').click(); await tick(); assert(r, 'dropdown', vertical.textContent.includes('Manage') && vertical.textContent.includes('Roles'), 'dropdown renders documented items field'); assert(r, 'openName', vertical.state.openName === 'admin', 'vertical dropdown stores its open name')
navLink(vertical, 'Roles').click(); await tick(); assert(r, 'item-shape', vertical.state.value === 'roles' && navLink(vertical, 'Roles').classList.contains('active'), 'item shape name and items select an active subitem')
assert(r, 'nav-event', navEvents.some(detail => detail.item?.name === 'home') && navEvents.some(detail => detail.item?.name === 'admin' && detail.open === true), 'nav event exposes item and dropdown open detail')
const selectedBeforeLogout = horizontal.state.value; navLink(horizontal, 'Logout').click(); await tick(); assert(r, 'noselect-nav', horizontal.state.value === selectedBeforeLogout && navEvents.some(detail => detail.name === 'logout' && detail.selected === false), 'noselect action keeps selection and still dispatches nav')
r.coverage.tested = [...cases]; r.coverage.percent = 100; r.consoleErrors = [...errors]; r.status = r.failed || r.consoleErrors.length ? 'failed' : 'passed'; document.querySelector('#status').className = 'badge text-bg-' + (r.status === 'passed' ? 'success' : 'danger'); document.querySelector('#status').textContent = r.status === 'passed' ? '通过' : '失败'; document.querySelector('#coverage').textContent = '覆盖 100%'; document.querySelector('#result').textContent = JSON.stringify(r, null, 2); return r }; document.querySelector('#runAll').onclick = window.runTest
</script></body></html>

View File

@ -18,9 +18,9 @@ item_types:
events:
change:
detail: Selected item name.
detail: Selected item name. Fires only when the selected value changes.
nav:
detail: '{ item } or { item, open } for vertical dropdown changes.'
detail: '{ item, name, selected } or { item, name, selected, open } for vertical dropdown changes. Fires for every activated button, including noselect action items.'
item_shape: "{ type, name?, label?, icon?, items?, bind?, class?, width? }"
@ -42,4 +42,5 @@ example: |
rules:
- Bind `$bind="Hash.nav"` when the selected navigation item must survive a page refresh.
- Use nav for actions such as Logout: `$onnav="if(event.detail.name==='logout') logout()"`. Use change only for selected-page state.
- Use the same `navItems` data for horizontal and vertical navigation when comparing layouts.

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -5,21 +5,23 @@
<title>components.data.DataGrid tests</title>
<script src="../../ui.js" components="data.DataGrid"></script>
<script>
const tableID = 'testTable'
const field = (id, name, type, settings) => ({ id, tableID, name, type, isIndex: false, memo: '', settings })
const gridFields = [
{ id: 'id', name: 'ID', type: 'number', settings: { width: 70, formType: 'number', pinned: 'left' } },
{ id: 'name', name: 'Name', type: 'text', settings: { width: 160, formType: 'text' } },
{ id: 'role', name: 'Role', type: 'text', settings: { width: 120, formType: 'select', options: [{ label: 'Administrator', value: 'admin' }, { label: 'Editor', value: 'editor' }] } },
{ id: 'score', name: 'Score', type: 'number', settings: { width: 100, formType: 'number', prefix: '$', decimals: 2 } },
{ id: 'active', name: 'Active', type: 'boolean', settings: { width: 90, formType: 'switch' } },
{ id: 'choices', name: 'Choices', type: 'object', settings: { width: 130, formType: 'checkbox', options: ['a', 'b'] } },
{ id: 'priority', name: 'Priority', type: 'text', settings: { width: 120, formType: 'radio', options: ['low', 'high'] } },
{ id: 'note', name: 'Note', type: 'text', settings: { width: 160, formType: 'textarea' } },
{ id: 'date', name: 'Date', type: 'date', settings: { width: 120, formType: 'date' } },
{ id: 'datetime', name: 'DateTime', type: 'datetime', settings: { width: 180, formType: 'datetime' } },
{ id: 'tags', name: 'Tags', type: 'object', settings: { width: 140, formType: 'TagsInput' } },
{ id: 'range', name: 'Range', type: 'date', settings: { width: 140, formType: 'DatePicker' } },
{ id: 'color', name: 'Color', type: 'text', settings: { width: 110, formType: 'ColorPicker' } },
{ id: 'icon', name: 'Icon', type: 'text', settings: { width: 120, formType: 'IconPicker' } }
field('f-id', 'id', 'bi', { width: 70, type: 'number', pinned: 'left' }),
field('f-name', 'name', 'v100', { width: 160, type: 'text' }),
field('f-role', 'role', 'v30', { width: 120, type: 'select', options: [{ label: 'Administrator', value: 'admin' }, { label: 'Editor', value: 'editor' }] }),
field('f-score', 'score', 'ff', { width: 100, type: 'number', prefix: '$', decimals: 2 }),
field('f-active', 'active', 'b', { width: 90, type: 'switch' }),
field('f-choices', 'choices', 'v4096', { width: 130, type: 'checkbox', options: ['a', 'b'] }),
field('f-priority', 'priority', 'v100', { width: 120, type: 'radio', options: ['low', 'high'] }),
field('f-note', 'note', 't', { width: 160, type: 'textarea' }),
field('f-date', 'date', 'd', { width: 120, type: 'date' }),
field('f-datetime', 'datetime', 'dt', { width: 180, type: 'datetime' }),
field('f-tags', 'tags', 'v4096', { width: 140, type: 'TagsInput' }),
field('f-range', 'range', 'd', { width: 140, type: 'DatePicker' }),
field('f-color', 'color', 'v100', { width: 110, type: 'ColorPicker' }),
field('f-icon', 'icon', 'v100', { width: 120, type: 'IconPicker' })
]
const gridRows = Array.from({ length: 200 }, (_, index) => ({ id: index + 1, name: 'User ' + (index + 1), role: index % 2 ? 'editor' : 'admin', score: index + 0.5, active: index % 2 === 0, choices: ['a'], priority: 'high', note: 'Note ' + (index + 1), date: '2026-07-12', datetime: '2026-07-12T09:30', tags: ['grid'], range: '2026-07-12', color: '#0d6efd', icon: 'star' }))
</script>
@ -28,22 +30,25 @@
<header class="d-flex align-items-center gap-2 mb-3 flex-shrink-0"><h1 class="h4 mb-0">components.data.DataGrid</h1><span id="status" class="badge text-bg-secondary">未测试</span><button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button><span id="coverage" class="small text-body-secondary"></span></header>
<main class="flex-fill overflow-hidden"><DataGrid id="usersGrid" class="h-100" editable $.state.fields="gridFields" $.state.list="gridRows"></DataGrid></main><pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mt-3 mb-0 flex-shrink-0 small"></pre>
</div><script>
const cases = ['editable', 'fields', 'list', 'field', 'formType', 'format', 'addRow', 'saveChanges', 'save-event', 'applySortFilter', 'sortConfig', 'filterConfig', 'virtual-scroll', 'selection-scroll', 'deleteField', 'DataGrid.registerFieldType', 'DataGrid.getFieldTypes']; const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event))); const tick = (ms = 260) => new Promise(resolve => setTimeout(resolve, ms)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
const cases = ['editable', 'fields', 'list', 'async-binding', 'field', 'formType', 'format', 'select-editor', 'addRow', 'saveChanges', 'save-event', 'applySortFilter', 'sortConfig', 'filterConfig', 'virtual-scroll', 'selection-scroll', 'deleteField', 'api-mode', 'advanced-mode', 'DataGrid.registerFieldType', 'DataGrid.getFieldTypes']; const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event))); const tick = (ms = 260) => new Promise(resolve => setTimeout(resolve, ms)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
window.runTest = async () => { errors.length = 0; const r = { name: 'components.data.DataGrid', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }; await tick(); const grid = document.querySelector('#usersGrid');
assert(r, 'editable', grid.editable === true && grid.querySelector('.dt-footer'), 'editable enables grid controls')
assert(r, 'fields', grid.state.fields.length === gridFields.length && grid.querySelectorAll('.dt-header-row .dt-cell').length === gridFields.length, 'fields render grid columns')
assert(r, 'list', grid.state.list.length === 200 && grid.querySelectorAll('.dt-body-row').length < 200, 'list renders a virtual row window')
grid.state.list = undefined; await tick(); grid.state.list = gridRows; await tick(); assert(r, 'async-binding', grid.state.list.length === gridRows.length && !errors.length, 'undefined async binding values do not break a later list update')
assert(r, 'field', grid.state.fields.every(field => field.id && field.name), 'field definitions expose id and name')
assert(r, 'formType', ['text', 'number', 'select', 'switch', 'checkbox', 'radio', 'textarea', 'date', 'datetime', 'TagsInput', 'DatePicker', 'ColorPicker', 'IconPicker'].every(type => grid.state.fields.some(field => field.settings.formType === type)), 'one grid visibly declares every supported editor type')
assert(r, 'formType', ['text', 'number', 'select', 'switch', 'checkbox', 'radio', 'textarea', 'date', 'datetime', 'TagsInput', 'DatePicker', 'ColorPicker', 'IconPicker'].every(type => grid.state.fields.some(field => field.settings.type === type)), 'one grid visibly declares every supported editor type')
assert(r, 'format', grid.format('admin', grid.state.fields[2]) === 'Administrator' && grid.format(1.5, grid.state.fields[3]) === '$1.50', 'field settings and formatters render values')
const roleCell = grid.querySelector('.dt-body-row .dt-cell[data-fidx="2"]'); grid.onMainDblClick({ target: roleCell }); await tick(); const roleSelect = grid.querySelector('.dt-editor-overlay select'); assert(r, 'select-editor', roleSelect?.value === 'admin' && grid.state.fields[2].settings.type === 'select', 'settings.type selects the editor and preserves its initial value'); grid.hideEditor(true)
const beforeRows = grid.state.list.length; grid.addRow(); await tick(); assert(r, 'addRow', grid.state.list.length === beforeRows + 1 && grid.state.isDirty, 'addRow adds a field-shaped row and marks changes dirty')
let save; grid.addEventListener('save', event => { save = event.detail }, { once: true }); grid.saveChanges(); assert(r, 'saveChanges', grid.state.isDirty === false, 'saveChanges clears pending changes'); assert(r, 'save-event', save?.list.length === beforeRows + 1 && save?.fields.length === gridFields.length, 'save event exposes list and fields')
grid.state.activeFieldId = 'name'; grid.applySortFilter({ sort: 'desc' }); await tick(); assert(r, 'applySortFilter', grid.state.list[0].name === 'User 99', 'applySortFilter sorts the current rows')
assert(r, 'sortConfig', grid.state.sortConfig.fieldId === 'name' && grid.state.sortConfig.direction === 'desc', 'sortConfig records field and direction')
grid.state.activeFieldId = 'role'; grid.state.filterConfig = { role: { mode: 'equals', value: 'admin', selectedValues: [] } }; grid.applySortFilter(); await tick(); assert(r, 'filterConfig', grid.state.list.every(row => row.role === 'admin'), 'filterConfig filters by field value')
grid.state.activeFieldId = 'f-name'; grid.applySortFilter({ sort: 'desc' }); await tick(); assert(r, 'applySortFilter', grid.state.list[0].name === 'User 99', 'applySortFilter sorts the current rows')
assert(r, 'sortConfig', grid.state.sortConfig.fieldId === 'f-name' && grid.state.sortConfig.direction === 'desc', 'sortConfig records field and direction')
grid.state.activeFieldId = 'f-role'; grid.state.filterConfig = { 'f-role': { mode: 'equals', value: 'admin', selectedValues: [] } }; grid.applySortFilter(); await tick(); assert(r, 'filterConfig', grid.state.list.every(row => row.role === 'admin'), 'filterConfig filters by field value')
const main = grid.querySelector('.dt-main'); main.scrollTop = main.scrollHeight; main.dispatchEvent(new Event('scroll')); await tick(); assert(r, 'virtual-scroll', grid.state._renderedList.length < grid.state.list.length && grid.state._listStartIndex > 0, 'virtual scroll updates the rendered row window')
const selected = grid.querySelector('.dt-body-row .dt-cell'); grid.onMainMouseDown({ target: selected, ctrlKey: false, metaKey: false }); main.scrollTop = 0; main.dispatchEvent(new Event('scroll')); await tick(400); main.scrollTop = main.scrollHeight; main.dispatchEvent(new Event('scroll')); await tick(400); assert(r, 'selection-scroll', grid.state.selectedRowCount === 1 && grid.querySelectorAll('.dt-cell-selected').length > 0, 'selected cells restore their visible highlight after virtual scrolling away and back')
const fieldCount = grid.state.fields.length; grid.state.activeField = grid.state.fields.at(-1); const removing = grid.deleteField(); await tick(550); document.querySelector('Dialog')?.querySelectorAll('button')[1]?.click(); await removing; await tick(); assert(r, 'deleteField', grid.state.fields.length === fieldCount - 1 && !grid.querySelector('.null') && !grid.textContent.includes('null'), 'deleteField removes its cells without orphan null rows')
const request = HTTP.request; const requests = []; HTTP.request = async opt => { requests.push(opt.data); if (opt.data.action === 'fields') return { ok: true, result: { ok: true, fields: [field('remote-role', 'Role', 'v30', { type: 'select', options: ['Admin', 'User'] })] } }; if (opt.data.action === 'query') return { ok: true, result: { ok: true, data: [{ id: 'remote-1', Role: 'Admin', creator: '_system' }] } }; return { ok: true, result: { ok: true } } }; grid.apiUrl = '/admin/table'; grid.state.table = 'remote-users'; await tick(); assert(r, 'api-mode', grid.state.fields.length === 1 && grid.state.list[0]?.Role === 'Admin' && requests.some(req => req.action === 'fields') && requests.some(req => req.action === 'query' && Array.isArray(req.filter) && Array.isArray(req.sort)), 'api mode loads fields and rows through safe filter and sort POST actions'); grid.state.advancedMode = true; await tick(); assert(r, 'advanced-mode', grid.state._displayFields.length === 6 && grid.state._displayFields.at(-1).name === 'updateTime', 'advanced mode adds internal read-only system columns without changing fields'); HTTP.request = request
DataGrid.registerFieldType({ value: 'test', label: 'Test', schema: [] }); assert(r, 'DataGrid.registerFieldType', DataGrid.getFieldTypes().some(type => type.value === 'test'), 'registerFieldType registers a public field type'); assert(r, 'DataGrid.getFieldTypes', DataGrid.getFieldTypes().length >= 8, 'getFieldTypes returns built-in and registered types')
r.coverage.tested = [...cases]; r.coverage.percent = 100; r.consoleErrors = [...errors]; r.status = r.failed || r.consoleErrors.length ? 'failed' : 'passed'; document.querySelector('#status').className = 'badge text-bg-' + (r.status === 'passed' ? 'success' : 'danger'); document.querySelector('#status').textContent = r.status === 'passed' ? '通过' : '失败'; document.querySelector('#coverage').textContent = '覆盖 100%'; document.querySelector('#result').textContent = JSON.stringify(r, null, 2); return r }
document.querySelector('#runAll').onclick = window.runTest

View File

@ -4,10 +4,16 @@ attributes:
editable:
type: boolean
behavior: Enable row, field, cell, and save controls.
api:
type: string
behavior: POST endpoint for the data-table action protocol. Requires state.table.
purpose: Editable data-API table manager. It consumes the field definitions returned by the data API and keeps them unchanged for save events.
state:
fields: Array of column definitions; assign before list.
list: Array of row objects; each key matches a field id.
table: Table name sent as request.name when api is configured.
fields: Array of data-API field definitions; assign before list.
list: Array of row objects; each key matches a field name.
_originalList: Internal unfiltered row snapshot.
sortConfig: "{ fieldId, direction }"
filterConfig: Per-field filter configuration.
@ -17,18 +23,20 @@ state:
editing:
formTypes: [text, number, select, checkbox, radio, switch, textarea, date, datetime, TagsInput, DatePicker, ColorPicker, IconPicker]
rule: Double-click an editable cell, then click outside the editor or press Enter to save its bound value.
field_settings: Field Name and Field Type are common settings. Advanced Settings shows read-only Field ID and Table ID, plus editable Database Type, Index, and Memo. Changing Field Type assigns its default database type; Database Type may be manually overridden.
filtering:
rule: Click a column header, enter a value in its menu, and the grid filters immediately. Use the menu controls for sort and reset.
field:
required: [id, name]
required: [id, tableID, name, type, settings]
fields:
id: Unique column id and row value key.
name: Header label.
type: Source value type.
id: _Fields record ID; used for field metadata operations.
tableID: Owning table ID.
name: Database column name and row value key.
type: Database storage type, for example v100, v30, ff, b, or dt. It is preserved when editing an unchanged UI type; changing settings.type assigns that UI type's default storage type.
memo: Field description.
settings: "{ width, pinned, formType, options, decimals, prefix, suffix, thousandSep, labelOn, labelOff }"
settings: "{ type, label, options, attrs, width, pinned, decimals, prefix, suffix, thousandSep, labelOn, labelOff }; type is the application/editor type."
formatter: Function receiving value and field.
methods:
@ -50,6 +58,20 @@ events:
remove:
detail: "{ items }"
api:
request: "POST { action, name: state.table, ...payload }"
actions:
fields: "response { ok, fields }; loaded when table changes."
query: "request { filter, sort, offset: 0, limit: 1000 }; response { ok, data, count } or { ok, list, count }. Filter items are { field, operator, value }; sort is ['field DESC']."
setField: "request { fields: [field] }; called after adding or editing a field."
removeField: "request { fields: [field] }; called after deleting a field."
save: "request { data: list }; called by Save."
remove: "request { data: items }; called by Delete."
behavior: Sorting and filters build validated filter plus sort structures and reload from the server. Pending edits require confirmation before a reload.
advanced_mode:
behavior: The footer toggle appends read-only internal columns id, creator, createTime, updater, and updateTime. They are not added to fields or included in field save requests.
global:
DataGrid:
methods:
@ -69,6 +91,9 @@ related:
rules:
- Bind fields and list with $.state.fields and $.state.list.
- DataGrid uses field.name (not field.id) to read and update each row value.
- DataGrid only supports data-API field definitions; use datatable for generic tabular display.
- Async fields and list bindings may be undefined before an API response; DataGrid waits for arrays.
- Give the DataGrid or its parent an explicit height so virtual scrolling has a viewport.
- Treat underscore-prefixed state fields as internal read-only diagnostics.
- Custom field editors must be registered in AutoForm before use.
@ -76,12 +101,9 @@ rules:
example: |
<script>
const orderFields = [
{ id: 'name', name: 'Name', type: 'text', settings: { formType: 'text' } },
{ id: 'total', name: 'Total', type: 'number', settings: { formType: 'number', prefix: '$', decimals: 2 } },
{ id: 'status', name: 'Status', type: 'text', settings: { formType: 'select', options: ['draft', 'paid'] } },
{ id: 'paid', name: 'Paid', type: 'boolean', settings: { formType: 'switch' } },
{ id: 'tags', name: 'Tags', type: 'object', settings: { formType: 'TagsInput' } },
{ id: 'due', name: 'Due', type: 'date', settings: { formType: 'DatePicker' } }
{ id: 'f-name', tableID: 'orders', name: 'name', type: 'v100', isIndex: true, memo: '', settings: { type: 'text' } },
{ id: 'f-total', tableID: 'orders', name: 'total', type: 'ff', isIndex: false, memo: '', settings: { type: 'number', prefix: '$', decimals: 2 } },
{ id: 'f-status', tableID: 'orders', name: 'status', type: 'v30', isIndex: false, memo: '', settings: { type: 'select', options: ['draft', 'paid'] } }
]
const orders = [{ name: 'Ada', total: 42, status: 'paid', paid: true, tags: ['priority'], due: '2026-07-12' }]
const saveRows = rows => console.log(rows)
@ -89,6 +111,7 @@ example: |
<div style="height: 480px">
<DataGrid id="orders" editable $.state.fields="orderFields" $.state.list="orders" $onsave="saveRows(event.detail.list)"></DataGrid>
</div>
<DataGrid editable api="/admin/table" $.state.table="Hash.table"></DataGrid>
tests:
- DataGrid.test.html

View File

@ -2113,7 +2113,71 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
.bi-globe-americas-fill::before { content: "\\f91b"; }
.bi-globe-asia-australia-fill::before { content: "\\f91c"; }
.bi-globe-central-south-asia-fill::before { content: "\\f91d"; }
.bi-globe-europe-africa-fill::before { content: "\\f91e"; }`));
.bi-globe-europe-africa-fill::before { content: "\\f91e"; }
/* Bootstrap 原生仅提供 .btn-link为链接按钮补充语义色变体。 */
.btn-link-primary,
.btn-link.btn-link-primary {
--bs-btn-color: var(--bs-primary);
--bs-btn-hover-color: color-mix(in srgb, var(--bs-primary) 85%, #000);
--bs-btn-active-color: color-mix(in srgb, var(--bs-primary) 80%, #000);
--bs-btn-disabled-color: var(--bs-primary);
}
.btn-link-secondary,
.btn-link.btn-link-secondary {
--bs-btn-color: var(--bs-secondary);
--bs-btn-hover-color: color-mix(in srgb, var(--bs-secondary) 85%, #000);
--bs-btn-active-color: color-mix(in srgb, var(--bs-secondary) 80%, #000);
--bs-btn-disabled-color: var(--bs-secondary);
}
.btn-link-success,
.btn-link.btn-link-success {
--bs-btn-color: var(--bs-success);
--bs-btn-hover-color: color-mix(in srgb, var(--bs-success) 85%, #000);
--bs-btn-active-color: color-mix(in srgb, var(--bs-success) 80%, #000);
--bs-btn-disabled-color: var(--bs-success);
}
.btn-link-info,
.btn-link.btn-link-info {
--bs-btn-color: var(--bs-info);
--bs-btn-hover-color: color-mix(in srgb, var(--bs-info) 85%, #000);
--bs-btn-active-color: color-mix(in srgb, var(--bs-info) 80%, #000);
--bs-btn-disabled-color: var(--bs-info);
}
.btn-link-warning,
.btn-link.btn-link-warning {
--bs-btn-color: var(--bs-warning);
--bs-btn-hover-color: color-mix(in srgb, var(--bs-warning) 85%, #000);
--bs-btn-active-color: color-mix(in srgb, var(--bs-warning) 80%, #000);
--bs-btn-disabled-color: var(--bs-warning);
}
.btn-link-danger,
.btn-link.btn-link-danger {
--bs-btn-color: var(--bs-danger);
--bs-btn-hover-color: color-mix(in srgb, var(--bs-danger) 85%, #000);
--bs-btn-active-color: color-mix(in srgb, var(--bs-danger) 80%, #000);
--bs-btn-disabled-color: var(--bs-danger);
}
.btn-link-light,
.btn-link.btn-link-light {
--bs-btn-color: var(--bs-light);
--bs-btn-hover-color: color-mix(in srgb, var(--bs-light) 85%, #000);
--bs-btn-active-color: color-mix(in srgb, var(--bs-light) 80%, #000);
--bs-btn-disabled-color: var(--bs-light);
}
.btn-link-dark,
.btn-link.btn-link-dark {
--bs-btn-color: var(--bs-dark);
--bs-btn-hover-color: color-mix(in srgb, var(--bs-dark) 85%, #000);
--bs-btn-active-color: color-mix(in srgb, var(--bs-dark) 80%, #000);
--bs-btn-disabled-color: var(--bs-dark);
}`));
document.head.appendChild(elementStyle);
}
} catch (e) {
@ -7192,15 +7256,17 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
const rgb = Bootstrap._hexToRgb(hex);
if (rgb) {
const rgbStr = `${rgb.r}, ${rgb.g}, ${rgb.b}`;
const hoverColor = Bootstrap._shadeColor(hex, 0.15);
const activeColor = Bootstrap._shadeColor(hex, 0.2);
root.style.setProperty(`--bs-${name}-rgb`, rgbStr);
cssPatch += `
.btn-${name} {
--bs-btn-bg: var(--bs-${name}) !important;
--bs-btn-border-color: var(--bs-${name}) !important;
--bs-btn-hover-bg: var(--bs-${name}) !important;
--bs-btn-hover-border-color: var(--bs-${name}) !important;
--bs-btn-active-bg: var(--bs-${name}) !important;
--bs-btn-active-border-color: var(--bs-${name}) !important;
--bs-btn-hover-bg: ${hoverColor} !important;
--bs-btn-hover-border-color: ${hoverColor} !important;
--bs-btn-active-bg: ${activeColor} !important;
--bs-btn-active-border-color: ${activeColor} !important;
--bs-btn-disabled-bg: var(--bs-${name}) !important;
--bs-btn-disabled-border-color: var(--bs-${name}) !important;
}
@ -7209,6 +7275,15 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
.border-${name} { border-color: var(--bs-${name}) !important; }
.badge.bg-${name} { background-color: var(--bs-${name}) !important; }
/* 语义色链接按钮 */
.btn-link-${name},
.btn-link.btn-link-${name} {
--bs-btn-color: var(--bs-${name});
--bs-btn-hover-color: ${hoverColor};
--bs-btn-active-color: ${activeColor};
--bs-btn-disabled-color: var(--bs-${name});
}
/* 表单控件补丁 (Form Controls) */
.form-check-input:checked {
background-color: var(--bs-primary) !important;
@ -7291,6 +7366,12 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
hex = hex.replace(shorthandRegex, (m, r, g, b) => r + r + g + g + b + b);
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null;
},
_shadeColor: (hex, amount) => {
const rgb = Bootstrap._hexToRgb(hex);
if (!rgb) return hex;
const shade = (value) => Math.round(value * (1 - amount)).toString(16).padStart(2, "0");
return `#${shade(rgb.r)}${shade(rgb.g)}${shade(rgb.b)}`;
}
};
if (typeof globalThis !== "undefined") {

File diff suppressed because one or more lines are too long

View File

@ -451,7 +451,7 @@
} 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(() => {
setTimeout(() => {
if (node.value !== String(result ?? "")) node.value = result;
});
} else if (node.isContentEditable) {

File diff suppressed because one or more lines are too long

View File

@ -1,11 +1,12 @@
{
"name": "@apigo.cc/ui",
"version": "1.0.0",
"private": true,
"version": "1.0.1",
"private": false,
"scripts": {
"build": "node tools/build.cjs",
"dev": "node tools/dev.cjs",
"test": "node tools/dev.cjs --test"
"test": "node tools/test.cjs",
"pub": "npm run build && npm publish --access public"
},
"files": [
"frameworks",

View File

@ -1,17 +1,13 @@
const { execFileSync } = require('node:child_process');
const { readdir, readFile, stat, writeFile } = require('node:fs/promises');
const { copyFile, readdir, readFile, stat, writeFile } = require('node:fs/promises');
const { join, relative, sep } = require('node:path');
const terser = require('terser');
const root = join(__dirname, '..');
const publicDirs = ['frameworks', 'utilities', 'components'];
const version = () => {
const tags = execFileSync('git', ['tag', '--sort=-v:refname'], { cwd: root, encoding: 'utf8' })
.trim().split(/\s+/).filter(tag => /^v\d+\.\d+\.\d+$/.test(tag));
if (!tags.length) return '1.0.0';
const [major, minor, patch] = tags[0].slice(1).split('.').map(Number);
return `${major}.${minor}.${patch + 1}`;
const frameworks = {
Bootstrap: join(root, '..', 'bootstrap', 'dist', 'bootstrap.js'),
State: join(root, '..', 'state', 'dist', 'state.js')
};
const filesBelow = async directory => {
@ -23,22 +19,20 @@ const filesBelow = async directory => {
return results.flat();
};
const setVersion = (text, nextVersion) => text
.replace(/"version": "\d+\.\d+\.\d+"/, `"version": "${nextVersion}"`)
.replace(/@apigo\.cc\/ui@\d+\.\d+\.\d+/g, `@apigo.cc/ui@${nextVersion}`);
(async () => {
const nextVersion = version();
await Promise.all(Object.entries(frameworks).map(([name, source]) => copyFile(source, join(root, 'frameworks', `${name}.js`))));
const sources = (await Promise.all(publicDirs.map(dir => filesBelow(join(root, dir))))).flat();
const sourceVersions = Object.fromEntries(await Promise.all(sources.map(async file => {
const sourceStats = await Promise.all(sources.map(async file => {
const path = relative(root, file).split(sep).join('/');
return [path, Math.floor((await stat(file)).mtimeMs).toString(36)];
})));
return [path, Math.floor((await stat(file)).mtimeMs)];
}));
const sourceVersions = Object.fromEntries(sourceStats.map(([path, modified]) => [path, modified.toString(36)]));
const buildVersion = Math.max(...sourceStats.map(([, modified]) => modified)).toString(36);
const uiFile = join(root, 'ui.js');
const uiSource = await readFile(uiFile, 'utf8');
const builtUi = uiSource.replace(
/const build = \{ version: '[^']*', files: \{[^\n]*\} \};/,
`const build = { version: '${nextVersion}', files: ${JSON.stringify(sourceVersions)} };`
`const build = { version: '${buildVersion}', files: ${JSON.stringify(sourceVersions)} };`
);
if (builtUi === uiSource && !uiSource.includes('const build =')) throw new Error('ui.js build manifest is missing');
await writeFile(uiFile, builtUi);
@ -50,9 +44,5 @@ const setVersion = (text, nextVersion) => text
};
await Promise.all([...sources, uiFile].map(minify));
for (const name of ['package.json', 'README.md', 'README.zh-CN.md', 'AI.yaml']) {
const file = join(root, name);
await writeFile(file, setVersion(await readFile(file, 'utf8'), nextVersion));
}
console.log(`Built @apigo.cc/ui@${nextVersion} (${sources.length} module files)`);
console.log(`Built @apigo.cc/ui (${sources.length} module files; cache ${buildVersion})`);
})();

61
tools/test.cjs Normal file
View File

@ -0,0 +1,61 @@
const { spawn } = require('node:child_process');
const { mkdtemp, rm } = require('node:fs/promises');
const { tmpdir } = require('node:os');
const { join } = require('node:path');
const net = require('node:net');
const { chromium } = require('playwright');
const root = join(__dirname, '..');
const aliases = { DataGrid: 'components/data/DataGrid.test.html', 'data.DataGrid': 'components/data/DataGrid.test.html' };
const requested = process.argv[2];
const pages = requested
? [aliases[requested] || `components/${requested.replace('.', '/')}.test.html`]
: ['components/base/API.test.html', 'components/base/AutoForm.test.html', 'components/base/Modal.test.html', 'components/base/Nav.test.html', 'components/data/DataGrid.test.html'];
const getPort = () => new Promise((resolve, reject) => {
const server = net.createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close(() => resolve(port)); });
});
const waitFor = async (url, timeout = 10000) => {
const started = Date.now();
while (Date.now() - started < timeout) {
try { if ((await fetch(url)).ok) return; } catch (_) { }
await new Promise(resolve => setTimeout(resolve, 100));
}
throw new Error(`Timed out waiting for ${url}`);
};
(async () => {
const [port, cdpPort] = await Promise.all([getPort(), getPort()]);
const profile = await mkdtemp(join(tmpdir(), 'apigo-ui-cdp-'));
const chrome = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome';
const server = spawn(process.execPath, [join(__dirname, 'serve.cjs')], { env: { ...process.env, PORT: String(port) }, stdio: 'inherit' });
const browserProcess = spawn(chrome, [`--headless=new`, `--remote-debugging-port=${cdpPort}`, `--user-data-dir=${profile}`, '--no-first-run', 'about:blank'], { stdio: 'ignore' });
let browser;
try {
await Promise.all([waitFor(`http://127.0.0.1:${port}/ui/ui.test.html`), waitFor(`http://127.0.0.1:${cdpPort}/json/version`)]);
browser = await chromium.connectOverCDP(`http://127.0.0.1:${cdpPort}`);
const context = browser.contexts()[0];
for (const pagePath of pages) {
const page = await context.newPage();
const errors = [];
page.on('pageerror', error => errors.push(error.message));
await page.goto(`http://127.0.0.1:${port}/ui/${pagePath}`, { waitUntil: 'networkidle' });
const result = await page.evaluate(() => globalThis.runTest());
await page.close();
if (errors.length || result?.status !== 'passed') throw new Error(`${pagePath}: ${errors.join('\n') || JSON.stringify(result)}`);
console.log(`Passed ${pagePath}`);
}
} finally {
await browser?.close();
await new Promise(resolve => {
if (browserProcess.exitCode !== null) return resolve();
browserProcess.once('exit', resolve);
browserProcess.kill();
setTimeout(resolve, 2000);
});
server.kill();
await rm(profile, { recursive: true, force: true });
}
})().catch(error => { console.error(error); process.exit(1); });

2
ui.js
View File

@ -1,6 +1,6 @@
(() => {
// Updated by `npm run build`. Source files stay directly executable.
const build = { version: '1.0.0', files: {"frameworks/Bootstrap.js":"mrgoatzb","frameworks/State.js":"mrgokg8d","utilities/HTTP.js":"mrg6t8zm","utilities/MouseMover.js":"mrg6t8zm","utilities/VirtualScroll.js":"mrg6t8zn","components/base/API.js":"mrggjm8m","components/base/AutoForm.js":"mrgoatz5","components/base/Dialog.js":"mrh7tjfb","components/base/List.js":"mrge46xl","components/base/Modal.js":"mrh4mibf","components/base/Nav.js":"mrh5kbf0","components/base/Resizer.js":"mrghfzrh","components/base/Toast.js":"mrghiycy","components/data/DataGrid.js":"mrhbi0rq","components/form/ColorPicker.js":"mrgn837c","components/form/DatePicker.js":"mrgn837c","components/form/IconPicker.js":"mrgoatz5","components/form/TagsInput.js":"mrgn837b"} };
const build = { version: 'mrjbct5d', files: {"frameworks/Bootstrap.js":"mrjbct5d","frameworks/State.js":"mrjbct5d","utilities/HTTP.js":"mrg6t8zm","utilities/MouseMover.js":"mrg6t8zm","utilities/VirtualScroll.js":"mrg6t8zn","components/base/API.js":"mrj8lfwz","components/base/AutoForm.js":"mrj10yku","components/base/Dialog.js":"mrh7tjfb","components/base/List.js":"mrge46xl","components/base/Modal.js":"mrj1zcg3","components/base/Nav.js":"mrj6haqp","components/base/Resizer.js":"mrghfzrh","components/base/Toast.js":"mrghiycy","components/data/DataGrid.js":"mrj8lxpb","components/form/ColorPicker.js":"mrgn837c","components/form/DatePicker.js":"mrgn837c","components/form/IconPicker.js":"mrgoatz5","components/form/TagsInput.js":"mrgn837b"} };
const script = document.currentScript;
if (!script || document.readyState !== 'loading') {
throw new Error('ui.js must be loaded by a normal <script> while the document is parsing');

2
ui.min.js vendored
View File

@ -1 +1 @@
(()=>{const o={version:"1.0.0",files:{"frameworks/Bootstrap.js":"mrgoatzb","frameworks/State.js":"mrgokg8d","utilities/HTTP.js":"mrg6t8zm","utilities/MouseMover.js":"mrg6t8zm","utilities/VirtualScroll.js":"mrg6t8zn","components/base/API.js":"mrggjm8m","components/base/AutoForm.js":"mrgoatz5","components/base/Dialog.js":"mrh7tjfb","components/base/List.js":"mrge46xl","components/base/Modal.js":"mrh4mibf","components/base/Nav.js":"mrh5kbf0","components/base/Resizer.js":"mrghfzrh","components/base/Toast.js":"mrghiycy","components/data/DataGrid.js":"mrhbi0rq","components/form/ColorPicker.js":"mrgn837c","components/form/DatePicker.js":"mrgn837c","components/form/IconPicker.js":"mrgoatz5","components/form/TagsInput.js":"mrgn837b"}},t=document.currentScript;if(!t||"loading"!==document.readyState)throw new Error("ui.js must be loaded by a normal <script> while the document is parsing");const e=(t?.getAttribute("components")||"").split(",").map(o=>o.trim()).filter(Boolean),s={HTTP:{path:"utilities/HTTP.js"},VirtualScroll:{path:"utilities/VirtualScroll.js"},MouseMover:{path:"utilities/MouseMover.js"},API:{path:"components/base/API.js",needs:["Toast"]},AutoForm:{path:"components/base/AutoForm.js",needs:["Toast"]},List:{path:"components/base/List.js",needs:["VirtualScroll"]},Modal:{path:"components/base/Modal.js"},Dialog:{path:"components/base/Dialog.js",needs:["Modal"]},Toast:{path:"components/base/Toast.js"},Nav:{path:"components/base/Nav.js"},Resizer:{path:"components/base/Resizer.js"},DataGrid:{path:"components/data/DataGrid.js",needs:["AutoForm","Dialog","Resizer","DatePicker","ColorPicker","IconPicker","TagsInput"]},DatePicker:{path:"components/form/DatePicker.js",needs:["AutoForm"]},ColorPicker:{path:"components/form/ColorPicker.js",needs:["AutoForm"]},IconPicker:{path:"components/form/IconPicker.js",needs:["AutoForm"]},TagsInput:{path:"components/form/TagsInput.js",needs:["AutoForm"]}},r={base:["API","AutoForm","List","Modal","Dialog","Toast","Nav","Resizer"],form:["DatePicker","ColorPicker","IconPicker","TagsInput"],data:["DataGrid"]},a=Object.fromEntries(Object.entries(r).flatMap(([o,t])=>t.map(t=>[`${o}.${t}`,t]))),n=e.flatMap(o=>r[o]||[a[o]||o]);if(!t.hasAttribute("components"))throw new Error('ui.js requires components="..."');const i=new URL(".",t.src),m=/(?:^|\/)ui\.min\.js$/.test(new URL(t.src).pathname),c=t=>document.write(`<script src="${(t=>{const e=m?t.replace(/\.js$/,".min.js"):t,s=new URL(e,i),r=o.files[t];return r&&s.searchParams.set("v",r),s.href})(t)}"><\/script>`);globalThis.Component||c("frameworks/State.js"),globalThis.bootstrap||c("frameworks/Bootstrap.js"),["HTTP","VirtualScroll","MouseMover"].forEach(o=>c(s[o].path));const p=new Set(["HTTP","VirtualScroll","MouseMover"]),l=o=>{if(p.has(o))return;p.add(o);const t=s[o];if(!t)throw new Error(`Unknown UI component: ${o}`);(t.needs||[]).forEach(l),c(t.path)};n.forEach(l)})();
(()=>{const o={version:"mrjbct5d",files:{"frameworks/Bootstrap.js":"mrjbct5d","frameworks/State.js":"mrjbct5d","utilities/HTTP.js":"mrg6t8zm","utilities/MouseMover.js":"mrg6t8zm","utilities/VirtualScroll.js":"mrg6t8zn","components/base/API.js":"mrj8lfwz","components/base/AutoForm.js":"mrj10yku","components/base/Dialog.js":"mrh7tjfb","components/base/List.js":"mrge46xl","components/base/Modal.js":"mrj1zcg3","components/base/Nav.js":"mrj6haqp","components/base/Resizer.js":"mrghfzrh","components/base/Toast.js":"mrghiycy","components/data/DataGrid.js":"mrj8lxpb","components/form/ColorPicker.js":"mrgn837c","components/form/DatePicker.js":"mrgn837c","components/form/IconPicker.js":"mrgoatz5","components/form/TagsInput.js":"mrgn837b"}},t=document.currentScript;if(!t||"loading"!==document.readyState)throw new Error("ui.js must be loaded by a normal <script> while the document is parsing");const e=(t?.getAttribute("components")||"").split(",").map(o=>o.trim()).filter(Boolean),s={HTTP:{path:"utilities/HTTP.js"},VirtualScroll:{path:"utilities/VirtualScroll.js"},MouseMover:{path:"utilities/MouseMover.js"},API:{path:"components/base/API.js",needs:["Toast"]},AutoForm:{path:"components/base/AutoForm.js",needs:["Toast"]},List:{path:"components/base/List.js",needs:["VirtualScroll"]},Modal:{path:"components/base/Modal.js"},Dialog:{path:"components/base/Dialog.js",needs:["Modal"]},Toast:{path:"components/base/Toast.js"},Nav:{path:"components/base/Nav.js"},Resizer:{path:"components/base/Resizer.js"},DataGrid:{path:"components/data/DataGrid.js",needs:["AutoForm","Dialog","Resizer","DatePicker","ColorPicker","IconPicker","TagsInput"]},DatePicker:{path:"components/form/DatePicker.js",needs:["AutoForm"]},ColorPicker:{path:"components/form/ColorPicker.js",needs:["AutoForm"]},IconPicker:{path:"components/form/IconPicker.js",needs:["AutoForm"]},TagsInput:{path:"components/form/TagsInput.js",needs:["AutoForm"]}},r={base:["API","AutoForm","List","Modal","Dialog","Toast","Nav","Resizer"],form:["DatePicker","ColorPicker","IconPicker","TagsInput"],data:["DataGrid"]},a=Object.fromEntries(Object.entries(r).flatMap(([o,t])=>t.map(t=>[`${o}.${t}`,t]))),n=e.flatMap(o=>r[o]||[a[o]||o]);if(!t.hasAttribute("components"))throw new Error('ui.js requires components="..."');const i=new URL(".",t.src),c=/(?:^|\/)ui\.min\.js$/.test(new URL(t.src).pathname),m=t=>document.write(`<script src="${(t=>{const e=c?t.replace(/\.js$/,".min.js"):t,s=new URL(e,i),r=o.files[t];return r&&s.searchParams.set("v",r),s.href})(t)}"><\/script>`);globalThis.Component||m("frameworks/State.js"),globalThis.bootstrap||m("frameworks/Bootstrap.js"),["HTTP","VirtualScroll","MouseMover"].forEach(o=>m(s[o].path));const p=new Set(["HTTP","VirtualScroll","MouseMover"]),l=o=>{if(p.has(o))return;p.add(o);const t=s[o];if(!t)throw new Error(`Unknown UI component: ${o}`);(t.needs||[]).forEach(l),m(t.path)};n.forEach(l)})();