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: Pages load `ui.js` directly from a CDN and explicitly declare required public APIs:
```html ```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. `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. 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). For AI-oriented API documentation, start with [AI.yaml](AI.yaml).

View File

@ -5,7 +5,7 @@
`@apigo.cc/ui` 是不需要生产构建的原生 JavaScript UI 组件库。页面使用一个 `ui.js` 入口,并且每个页面都显式写出使用的组件: `@apigo.cc/ui` 是不需要生产构建的原生 JavaScript UI 组件库。页面使用一个 `ui.js` 入口,并且每个页面都显式写出使用的组件:
```html ```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。 `ui.js` 默认加载 `frameworks/` 和底层 `utilities/``components` 中只填写页面直接使用的公开组件名,例如 `components="List,AutoForm,Resizer"`;组件依赖会自动加载,不需要填写 state、bootstrap、HTTP、VirtualScroll 或 MouseMover。
@ -14,4 +14,4 @@
AI 使用说明从 [AI.yaml](AI.yaml) 开始;文档与源码同目录,组件示例直接写在对应 YAML 中。 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({ container.request = globalThis.NewState({
url: '', url: '',
method: 'GET', method: '',
headers: {}, headers: {},
data: null, data: null,
timeout: 10000, timeout: 10000,
@ -32,6 +32,7 @@ const APIComponent = globalThis.Component.register('API', container => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = { ...container.request, ...opt } const req = { ...container.request, ...opt }
if (!req.url) throw new Error('.url is required') 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 } req.headers = { ...container.request.headers, ...opt.headers }
container.response.loading = true container.response.loading = true
globalThis.HTTP.request(req).then(resp => { 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 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 }) } } const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
window.runTest = async () => { 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() await tick()
let autoResponses = 0; autoApi.addEventListener('response', () => { autoResponses++ }) let autoResponses = 0; autoApi.addEventListener('response', () => { autoResponses++ })
autoApi.request.url = '/__http__/json?next=1'; await tick() 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, '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, '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') assert(testResult, 'result-binding', document.querySelector('#autoResult').textContent === 'GET', 'response result is usable in declarative DOM bindings')
let responseEvent let responseEvent
manualApi.addEventListener('response', event => { responseEvent = event.detail }, { once: true }) manualApi.addEventListener('response', event => { responseEvent = event.detail }, { once: true })
const response = await manualApi.do(successRequest) const response = await manualApi.do(successRequest)
assert(testResult, 'do', response.ok && manualApi.result.method === 'GET', 'do() sends a request and updates result') 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, '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 let failed = false; let errorEvent
manualApi.addEventListener('error', event => { errorEvent = event.detail }, { once: true }) manualApi.addEventListener('error', event => { errorEvent = event.detail }, { once: true })
try { await manualApi.do({ ...errorRequest, noui: true }) } catch (error) { failed = 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', 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, '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') 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 document.querySelector('#runAll').onclick = window.runTest
</script> </script>

View File

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

View File

@ -6,8 +6,12 @@ const AUTOFORM_BLUEPRINT = globalThis.Util.makeDom(/*html*/`
<div class="auto-form-root"> <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()"> <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.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"> <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> <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'}"> <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> </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 === '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>
</div> </div>
</template> </template>
</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'}"> <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; } .auto-form-root .form-switch .form-check-input { width: 2em; }
</style>`) </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 => { 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 ensureProxy = v => (v && typeof v === 'object' && !v.__isProxy) ? globalThis.NewState(v) : v;
const setData = value => { 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'" } { name: 'hidden', label: 'Hidden', type: 'text', if: "this.data.role === 'user'" }
] ]
const compactSchema = [{ name: 'name', label: 'Name', type: 'text' }] 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 saveRequest = { url: '/__http__/json', method: 'POST' }
const errorRequest = { url: '/__http__/api-error', method: 'POST' } const errorRequest = { url: '/__http__/api-error', method: 'POST' }
</script> </script>
@ -45,11 +49,12 @@
<AutoForm id="saveForm" nobutton $.api="saveApi" $.state.data="profile" $.state.schema="compactSchema"></AutoForm> <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="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="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> <button id="runAll" type="button">测试全部</button>
<pre id="result"></pre> <pre id="result"></pre>
<script> <script>
const tick = () => new Promise(resolve => setTimeout(resolve, 80)) 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 }) } } const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
window.runTest = async () => { 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 } } 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, '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, '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') 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') 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' 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') assert(testResult, 'custom-type', !!form.querySelector('TagsInput'), 'registered AutoForm component type renders its component')

View File

@ -20,10 +20,13 @@ properties:
schema: schema:
item: "{name, type, label?, setting?, options?, placeholder?, if?}" 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'}]" options: "select/checkbox/radio options: ['A', {value:'b', label:'B'}]"
setting: "attributes copied to the control; DatePicker rangeEnd is an example" 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"`.' 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: submit:
source: Set api to an API element, or set request.url. source: Set api to an API element, or set request.url.
@ -42,6 +45,10 @@ examples:
] ]
</script> </script>
<AutoForm $.state.data="profile" $.state.schema="profileSchema"></AutoForm> <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: | submit_api: |
<script> <script>
const profile = { name: 'Ada' } const profile = { name: 'Ada' }

View File

@ -15,7 +15,7 @@ globalThis.Component.register('Modal', container => {
container.hide = (...args) => container.modal.hide(...args) container.hide = (...args) => container.modal.hide(...args)
}, globalThis.Util.makeDom(/*html*/` }, globalThis.Util.makeDom(/*html*/`
<div class="modal fade" data-bs-backdrop="static"> <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 $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"> <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> <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> <pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mt-3 mb-0 flex-shrink-0 small"></pre>
</div> </div>
<script> <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 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 }) } } 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 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, '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, '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, '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') 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 } 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 document.querySelector('#runAll').onclick = window.runTest

View File

@ -3,6 +3,9 @@ name: Modal
binding: binding:
behavior: Bind a boolean with $bind to show or hide the modal. 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: state:
title: Default header title. title: Default header title.
type: Bootstrap contextual type. Default is primary. type: Bootstrap contextual type. Default is primary.

View File

@ -11,20 +11,21 @@ globalThis.Component.register('Nav', container => {
}) })
container.click = (item, noselect) => { container.click = (item, noselect) => {
if (!item.noselect && !noselect) { const selected = !item.noselect && !noselect
if (selected) {
container.state.value = item.name container.state.value = item.name
container.dispatchEvent(new CustomEvent('change', { detail: item.name, bubbles: false })) 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.clickSubitem = item => {
container.state.value = item.name container.state.value = item.name
container.dispatchEvent(new CustomEvent('change', { detail: item.name, bubbles: false })) 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.toggleGroup = item => {
container.state.openName = container.state.openName === item.name ? null : item.name 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*/` }, 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;' : ''"> <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>
<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;' : ''"> <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 || []"> <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'"> <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> <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> </template>
@ -86,7 +87,7 @@ globalThis.Component.register('Nav', container => {
<button type="button" $class="nav-link dropdown-toggle \${item.class || ''}" data-bs-toggle="dropdown"> <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> <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> </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 $each="item.items || item.list" as="subitem">
<template $if="subitem.type==='label'"> <template $if="subitem.type==='label'">
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div> <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> <title>components.Nav tests</title><script src="../../ui.js" components="Nav"></script>
<script> <script>
const navBrand = { icon: 'box', label: 'Apigo' } 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> </script>
</head> </head>
<body class="vh-100 overflow-hidden bg-body-tertiary"><div class="container-fluid h-100 py-3 d-flex flex-column"> <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> <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> <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> </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)) 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, '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') 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, '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') 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') 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 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> </script></body></html>

View File

@ -18,9 +18,9 @@ item_types:
events: events:
change: change:
detail: Selected item name. detail: Selected item name. Fires only when the selected value changes.
nav: 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? }" item_shape: "{ type, name?, label?, icon?, items?, bind?, class?, width? }"
@ -42,4 +42,5 @@ example: |
rules: rules:
- Bind `$bind="Hash.nav"` when the selected navigation item must survive a page refresh. - 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. - 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> <title>components.data.DataGrid tests</title>
<script src="../../ui.js" components="data.DataGrid"></script> <script src="../../ui.js" components="data.DataGrid"></script>
<script> <script>
const tableID = 'testTable'
const field = (id, name, type, settings) => ({ id, tableID, name, type, isIndex: false, memo: '', settings })
const gridFields = [ const gridFields = [
{ id: 'id', name: 'ID', type: 'number', settings: { width: 70, formType: 'number', pinned: 'left' } }, field('f-id', 'id', 'bi', { width: 70, type: 'number', pinned: 'left' }),
{ id: 'name', name: 'Name', type: 'text', settings: { width: 160, formType: 'text' } }, field('f-name', 'name', 'v100', { width: 160, type: 'text' }),
{ id: 'role', name: 'Role', type: 'text', settings: { width: 120, formType: 'select', options: [{ label: 'Administrator', value: 'admin' }, { label: 'Editor', value: 'editor' }] } }, field('f-role', 'role', 'v30', { width: 120, type: 'select', options: [{ label: 'Administrator', value: 'admin' }, { label: 'Editor', value: 'editor' }] }),
{ id: 'score', name: 'Score', type: 'number', settings: { width: 100, formType: 'number', prefix: '$', decimals: 2 } }, field('f-score', 'score', 'ff', { width: 100, type: 'number', prefix: '$', decimals: 2 }),
{ id: 'active', name: 'Active', type: 'boolean', settings: { width: 90, formType: 'switch' } }, field('f-active', 'active', 'b', { width: 90, type: 'switch' }),
{ id: 'choices', name: 'Choices', type: 'object', settings: { width: 130, formType: 'checkbox', options: ['a', 'b'] } }, field('f-choices', 'choices', 'v4096', { width: 130, type: 'checkbox', options: ['a', 'b'] }),
{ id: 'priority', name: 'Priority', type: 'text', settings: { width: 120, formType: 'radio', options: ['low', 'high'] } }, field('f-priority', 'priority', 'v100', { width: 120, type: 'radio', options: ['low', 'high'] }),
{ id: 'note', name: 'Note', type: 'text', settings: { width: 160, formType: 'textarea' } }, field('f-note', 'note', 't', { width: 160, type: 'textarea' }),
{ id: 'date', name: 'Date', type: 'date', settings: { width: 120, formType: 'date' } }, field('f-date', 'date', 'd', { width: 120, type: 'date' }),
{ id: 'datetime', name: 'DateTime', type: 'datetime', settings: { width: 180, formType: 'datetime' } }, field('f-datetime', 'datetime', 'dt', { width: 180, type: 'datetime' }),
{ id: 'tags', name: 'Tags', type: 'object', settings: { width: 140, formType: 'TagsInput' } }, field('f-tags', 'tags', 'v4096', { width: 140, type: 'TagsInput' }),
{ id: 'range', name: 'Range', type: 'date', settings: { width: 140, formType: 'DatePicker' } }, field('f-range', 'range', 'd', { width: 140, type: 'DatePicker' }),
{ id: 'color', name: 'Color', type: 'text', settings: { width: 110, formType: 'ColorPicker' } }, field('f-color', 'color', 'v100', { width: 110, type: 'ColorPicker' }),
{ id: 'icon', name: 'Icon', type: 'text', settings: { width: 120, formType: 'IconPicker' } } 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' })) 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> </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> <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> <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> </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'); 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, '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, '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') 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, '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') 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') 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') 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') 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 === 'name' && grid.state.sortConfig.direction === 'desc', 'sortConfig records field and direction') assert(r, 'sortConfig', grid.state.sortConfig.fieldId === 'f-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-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 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 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 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') 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 } 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 document.querySelector('#runAll').onclick = window.runTest

View File

@ -4,10 +4,16 @@ attributes:
editable: editable:
type: boolean type: boolean
behavior: Enable row, field, cell, and save controls. 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: state:
fields: Array of column definitions; assign before list. table: Table name sent as request.name when api is configured.
list: Array of row objects; each key matches a field id. 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. _originalList: Internal unfiltered row snapshot.
sortConfig: "{ fieldId, direction }" sortConfig: "{ fieldId, direction }"
filterConfig: Per-field filter configuration. filterConfig: Per-field filter configuration.
@ -17,18 +23,20 @@ state:
editing: editing:
formTypes: [text, number, select, checkbox, radio, switch, textarea, date, datetime, TagsInput, DatePicker, ColorPicker, IconPicker] 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. 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: 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. 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: field:
required: [id, name] required: [id, tableID, name, type, settings]
fields: fields:
id: Unique column id and row value key. id: _Fields record ID; used for field metadata operations.
name: Header label. tableID: Owning table ID.
type: Source value type. 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. 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. formatter: Function receiving value and field.
methods: methods:
@ -50,6 +58,20 @@ events:
remove: remove:
detail: "{ items }" 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: global:
DataGrid: DataGrid:
methods: methods:
@ -69,6 +91,9 @@ related:
rules: rules:
- Bind fields and list with $.state.fields and $.state.list. - 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. - 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. - Treat underscore-prefixed state fields as internal read-only diagnostics.
- Custom field editors must be registered in AutoForm before use. - Custom field editors must be registered in AutoForm before use.
@ -76,12 +101,9 @@ rules:
example: | example: |
<script> <script>
const orderFields = [ const orderFields = [
{ id: 'name', name: 'Name', type: 'text', settings: { formType: 'text' } }, { id: 'f-name', tableID: 'orders', name: 'name', type: 'v100', isIndex: true, memo: '', settings: { type: 'text' } },
{ id: 'total', name: 'Total', type: 'number', settings: { formType: 'number', prefix: '$', decimals: 2 } }, { id: 'f-total', tableID: 'orders', name: 'total', type: 'ff', isIndex: false, memo: '', settings: { type: 'number', prefix: '$', decimals: 2 } },
{ id: 'status', name: 'Status', type: 'text', settings: { formType: 'select', options: ['draft', 'paid'] } }, { id: 'f-status', tableID: 'orders', name: 'status', type: 'v30', isIndex: false, memo: '', settings: { type: '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' } }
] ]
const orders = [{ name: 'Ada', total: 42, status: 'paid', paid: true, tags: ['priority'], due: '2026-07-12' }] const orders = [{ name: 'Ada', total: 42, status: 'paid', paid: true, tags: ['priority'], due: '2026-07-12' }]
const saveRows = rows => console.log(rows) const saveRows = rows => console.log(rows)
@ -89,6 +111,7 @@ example: |
<div style="height: 480px"> <div style="height: 480px">
<DataGrid id="orders" editable $.state.fields="orderFields" $.state.list="orders" $onsave="saveRows(event.detail.list)"></DataGrid> <DataGrid id="orders" editable $.state.fields="orderFields" $.state.list="orders" $onsave="saveRows(event.detail.list)"></DataGrid>
</div> </div>
<DataGrid editable api="/admin/table" $.state.table="Hash.table"></DataGrid>
tests: tests:
- DataGrid.test.html - DataGrid.test.html

View File

@ -2113,7 +2113,71 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
.bi-globe-americas-fill::before { content: "\\f91b"; } .bi-globe-americas-fill::before { content: "\\f91b"; }
.bi-globe-asia-australia-fill::before { content: "\\f91c"; } .bi-globe-asia-australia-fill::before { content: "\\f91c"; }
.bi-globe-central-south-asia-fill::before { content: "\\f91d"; } .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); document.head.appendChild(elementStyle);
} }
} catch (e) { } catch (e) {
@ -7192,15 +7256,17 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
const rgb = Bootstrap._hexToRgb(hex); const rgb = Bootstrap._hexToRgb(hex);
if (rgb) { if (rgb) {
const rgbStr = `${rgb.r}, ${rgb.g}, ${rgb.b}`; 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); root.style.setProperty(`--bs-${name}-rgb`, rgbStr);
cssPatch += ` cssPatch += `
.btn-${name} { .btn-${name} {
--bs-btn-bg: var(--bs-${name}) !important; --bs-btn-bg: var(--bs-${name}) !important;
--bs-btn-border-color: var(--bs-${name}) !important; --bs-btn-border-color: var(--bs-${name}) !important;
--bs-btn-hover-bg: var(--bs-${name}) !important; --bs-btn-hover-bg: ${hoverColor} !important;
--bs-btn-hover-border-color: var(--bs-${name}) !important; --bs-btn-hover-border-color: ${hoverColor} !important;
--bs-btn-active-bg: var(--bs-${name}) !important; --bs-btn-active-bg: ${activeColor} !important;
--bs-btn-active-border-color: var(--bs-${name}) !important; --bs-btn-active-border-color: ${activeColor} !important;
--bs-btn-disabled-bg: var(--bs-${name}) !important; --bs-btn-disabled-bg: var(--bs-${name}) !important;
--bs-btn-disabled-border-color: 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; } .border-${name} { border-color: var(--bs-${name}) !important; }
.badge.bg-${name} { background-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 Controls) */
.form-check-input:checked { .form-check-input:checked {
background-color: var(--bs-primary) !important; 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); 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); 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; 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") { 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") { } else if (node.type === "radio") {
if (node.checked !== (node.value === String(result ?? ""))) node.checked = node.value === String(result ?? ""); if (node.checked !== (node.value === String(result ?? ""))) node.checked = node.value === String(result ?? "");
} else if ("value" in node && node.type !== "file") { } else if ("value" in node && node.type !== "file") {
Promise.resolve().then(() => { setTimeout(() => {
if (node.value !== String(result ?? "")) node.value = result; if (node.value !== String(result ?? "")) node.value = result;
}); });
} else if (node.isContentEditable) { } 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", "name": "@apigo.cc/ui",
"version": "1.0.0", "version": "1.0.1",
"private": true, "private": false,
"scripts": { "scripts": {
"build": "node tools/build.cjs", "build": "node tools/build.cjs",
"dev": "node tools/dev.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": [ "files": [
"frameworks", "frameworks",

View File

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

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. // 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; const script = document.currentScript;
if (!script || document.readyState !== 'loading') { if (!script || document.readyState !== 'loading') {
throw new Error('ui.js must be loaded by a normal <script> while the document is parsing'); 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)})();