feat(ui): 建立原生组件库与测试体系(by AI)
This commit is contained in:
commit
e78df56494
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
test-results/
|
||||
playwright-report/
|
||||
.DS_Store
|
||||
*.log
|
||||
TODO.yaml
|
||||
47
AI.yaml
Normal file
47
AI.yaml
Normal file
@ -0,0 +1,47 @@
|
||||
package: apigo.cc/web/ui
|
||||
|
||||
rules:
|
||||
- Read only documents required by the task.
|
||||
- Preserve declarative bindings when extending a component.
|
||||
- Do not change framework internals unless framework changes are requested.
|
||||
- Add or update a regression test for every public behavior change.
|
||||
- Before declaring a public API complete, compare its YAML attributes, properties, methods, events, slots, state fields, options, and rules against named test assertions and examples. Every documented item needs coverage; examples must visibly demonstrate the core behavior declaratively.
|
||||
- In an ordered value list, the first value is the default.
|
||||
- Treat each component test as a concise, declarative usage sample: prepare immutable top-level `const` data before DOM parsing, then bind it directly (for example `$.state.list="users"`). Do not add State or window bridge assignments unless shared mutable state is required.
|
||||
- Prefer framework directives ($if, $bind, $onclick, slots) and Bootstrap classes over imperative page setup or custom CSS. Keep test actions limited to assertions and interaction verification.
|
||||
- Test pages use full-height flex layouts: the component area fills remaining space and result output is a bounded, internally scrollable region.
|
||||
- The outer test console keeps the selected test active and represents pass/fail with a visible status badge; detailed output belongs inside the selected test page.
|
||||
- Public source and YAML use the same PascalCase basename (for example `components/List.js` and `components/List.yaml`, `frameworks/State.js` and `frameworks/State.yaml`).
|
||||
|
||||
loading:
|
||||
example: '<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="List,AutoForm"></script>'
|
||||
rules:
|
||||
- Load ui.js in head without async, defer, or type="module".
|
||||
- Always write components with every public component used by the page.
|
||||
- Do not list utilities or framework dependencies.
|
||||
- ui.js loads frameworks/State.js and frameworks/Bootstrap.js beside itself.
|
||||
- npm run build writes source modification versions into ui.js; ui.min.js loads matching adjacent .min.js files.
|
||||
|
||||
framework:
|
||||
- frameworks/State.yaml
|
||||
- frameworks/Bootstrap.yaml
|
||||
|
||||
components:
|
||||
- components/API.yaml
|
||||
- components/AutoForm.yaml
|
||||
- components/List.yaml
|
||||
- components/Modal.yaml
|
||||
- components/Dialog.yaml
|
||||
- components/Toast.yaml
|
||||
- components/Nav.yaml
|
||||
- components/Resizer.yaml
|
||||
- components/form/DatePicker.yaml
|
||||
- components/form/ColorPicker.yaml
|
||||
- components/form/IconPicker.yaml
|
||||
- components/form/TagsInput.yaml
|
||||
- components/DataTable.yaml
|
||||
- components/DataChart.yaml
|
||||
|
||||
utilities:
|
||||
- utilities/HTTP.yaml
|
||||
- utilities/VirtualScroll.yaml
|
||||
19
README.md
Normal file
19
README.md
Normal file
@ -0,0 +1,19 @@
|
||||
# @apigo.cc/ui
|
||||
|
||||
[中文文档](README.zh-CN.md) | English
|
||||
|
||||
`@apigo.cc/ui` is the build-free UI package for the Apigo browser framework.
|
||||
|
||||
Pages load `ui.js` directly from a CDN and explicitly declare required public APIs:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="API,AutoForm,List,DataTable"></script>
|
||||
```
|
||||
|
||||
`ui.js` always loads every file in `frameworks/` and `utilities/`. The `components` attribute only lists public components used by the page.
|
||||
|
||||
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.
|
||||
|
||||
For AI-oriented API documentation, start with [AI.yaml](AI.yaml).
|
||||
17
README.zh-CN.md
Normal file
17
README.zh-CN.md
Normal file
@ -0,0 +1,17 @@
|
||||
# @apigo.cc/ui
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`@apigo.cc/ui` 是不需要生产构建的原生 JavaScript UI 组件库。页面使用一个 `ui.js` 入口,并且每个页面都显式写出使用的组件:
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="API,AutoForm,List,DataTable"></script>
|
||||
```
|
||||
|
||||
`ui.js` 默认加载 `frameworks/` 和底层 `utilities/`。`components` 中只填写页面直接使用的公开组件名,例如 `components="List,AutoForm,Resizer"`;组件依赖会自动加载,不需要填写 state、bootstrap、HTTP、VirtualScroll 或 MouseMover。
|
||||
|
||||
必须把脚本放在 `<head>`,并使用普通 `<script>`。不要使用 `async`、`defer` 或 `type="module"`,这样 state 才能在 DOMContentLoaded 前完成组件注册,并对完整页面执行首次扫描。
|
||||
|
||||
AI 使用说明从 [AI.yaml](AI.yaml) 开始;文档与源码同目录,组件示例直接写在对应 YAML 中。
|
||||
|
||||
`npm run build` 不会打包源码,也不会生成 `dist`。它会更新发布版本、把各源码文件的修改时间版本写入 `ui.js`,并在源码旁生成 `.min.js`。加载 `ui.min.js` 时,入口会自动改为加载对应的 framework、utility 和 component 的 `.min.js`。
|
||||
65
components/API.js
Normal file
65
components/API.js
Normal file
@ -0,0 +1,65 @@
|
||||
/**
|
||||
* API Component Module
|
||||
*/
|
||||
const APIComponent = globalThis.Component.register('API', container => {
|
||||
const presetRequest = container.request && typeof container.request === 'object' ? container.request : {}
|
||||
const presetResponse = container.response && typeof container.response === 'object' ? container.response : {}
|
||||
const presetResult = container.result && typeof container.result === 'object' ? container.result : {}
|
||||
|
||||
container.request = globalThis.NewState({
|
||||
url: '',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
data: null,
|
||||
timeout: 10000,
|
||||
responseType: '',
|
||||
...presetRequest,
|
||||
headers: { ...(presetRequest.headers || {}) }
|
||||
})
|
||||
container.response = globalThis.NewState({
|
||||
loading: false,
|
||||
ok: null,
|
||||
status: null,
|
||||
error: null,
|
||||
headers: {},
|
||||
responseType: '',
|
||||
result: null,
|
||||
...presetResponse,
|
||||
headers: { ...(presetResponse.headers || {}) }
|
||||
})
|
||||
container.result = globalThis.NewState(presetResult)
|
||||
container.do = (opt = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = { ...container.request, ...opt }
|
||||
if (!req.url) throw new Error('.url is required')
|
||||
req.headers = { ...container.request.headers, ...opt.headers }
|
||||
container.response.loading = true
|
||||
globalThis.HTTP.request(req).then(resp => {
|
||||
Object.keys(resp).forEach(k => { if (k !== 'result') container.response[k] = resp[k] })
|
||||
if (resp.result && typeof resp.result === 'object' && container.result && typeof container.result === 'object') {
|
||||
Object.assign(container.result, resp.result)
|
||||
} else {
|
||||
container.result = resp.result
|
||||
}
|
||||
container.response.loading = false
|
||||
if (resp.ok === false) throw new Error(resp.error)
|
||||
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
|
||||
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
|
||||
resolve(resp)
|
||||
}).catch(err => {
|
||||
if (!opt.noui) globalThis.Toast?.show(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
reject(err)
|
||||
})
|
||||
})
|
||||
}
|
||||
let _autoTimer = null
|
||||
container.request.__watch(null, () => {
|
||||
if (!container.hasAttribute('auto') || !container.request.url) return
|
||||
if (_autoTimer) return
|
||||
_autoTimer = Promise.resolve().then(() => {
|
||||
container.do()
|
||||
_autoTimer = null
|
||||
})
|
||||
})
|
||||
})
|
||||
1
components/API.min.js
vendored
Normal file
1
components/API.min.js
vendored
Normal file
@ -0,0 +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})))})});
|
||||
47
components/API.test.html
Normal file
47
components/API.test.html
Normal file
@ -0,0 +1,47 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>components.API tests</title>
|
||||
<script src="../ui.js" components="API"></script>
|
||||
<script>
|
||||
const successRequest = { url: '/__http__/json', method: 'GET' }
|
||||
const errorRequest = { url: '/__http__/api-error', method: 'GET' }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<API id="autoApi" auto $.request="successRequest"></API>
|
||||
<API id="manualApi"></API>
|
||||
<div id="autoResult" $text="autoApi.result.method"></div>
|
||||
<button id="runAll" type="button">测试全部</button>
|
||||
<pre id="result"></pre>
|
||||
<script>
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 80))
|
||||
const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
|
||||
window.runTest = async () => {
|
||||
const testResult = { name: 'components.API', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: ['auto', 'request', 'response', 'result', 'do', 'response-event', 'error-event', 'noui'], percent: 0 } }
|
||||
await tick()
|
||||
let autoResponses = 0; autoApi.addEventListener('response', () => { autoResponses++ })
|
||||
autoApi.request.url = '/__http__/json?next=1'; await tick()
|
||||
assert(testResult, 'auto', autoApi.response.ok === true && autoApi.result.method === 'GET' && autoResponses === 1, 'auto requests again after request changes')
|
||||
assert(testResult, 'request', manualApi.request.url === '' && manualApi.request.method === 'GET' && manualApi.request.data === null && manualApi.request.timeout === 10000 && manualApi.request.responseType === '' && typeof manualApi.request.headers === 'object', 'request exposes documented defaults')
|
||||
assert(testResult, 'response', autoApi.response.loading === false && autoApi.response.status === 200 && autoApi.response.responseType === 'json' && typeof autoApi.response.headers === 'object', 'response exposes loading, status, headers, and responseType')
|
||||
assert(testResult, 'result-binding', document.querySelector('#autoResult').textContent === 'GET', 'response result is usable in declarative DOM bindings')
|
||||
let responseEvent
|
||||
manualApi.addEventListener('response', event => { responseEvent = event.detail }, { once: true })
|
||||
const response = await manualApi.do(successRequest)
|
||||
assert(testResult, 'do', response.ok && manualApi.result.method === 'GET', 'do() sends a request and updates result')
|
||||
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')
|
||||
let failed = false; let errorEvent
|
||||
manualApi.addEventListener('error', event => { errorEvent = event.detail }, { once: true })
|
||||
try { await manualApi.do({ ...errorRequest, noui: true }) } catch (error) { failed = true }
|
||||
assert(testResult, 'error', failed && manualApi.response.status === 200 && manualApi.response.ok === true && manualApi.result.error === 'application failed', 'application error rejects and preserves response state')
|
||||
assert(testResult, 'error-event', errorEvent instanceof Error && errorEvent.message === 'application failed', 'error event exposes Error detail')
|
||||
assert(testResult, 'noui', !document.querySelector('Toast'), 'noui suppresses automatic error Toast')
|
||||
testResult.coverage.tested = ['auto', 'request', 'response', 'result', 'do', 'response-event', 'error-event', 'noui']; testResult.coverage.percent = 100; testResult.status = testResult.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult
|
||||
}
|
||||
document.querySelector('#runAll').onclick = window.runTest
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
53
components/API.yaml
Normal file
53
components/API.yaml
Normal file
@ -0,0 +1,53 @@
|
||||
name: API
|
||||
|
||||
attributes:
|
||||
auto:
|
||||
type: boolean
|
||||
behavior: Request after request changes when request.url exists.
|
||||
|
||||
properties:
|
||||
request:
|
||||
default:
|
||||
url: ''
|
||||
method: GET
|
||||
headers: {}
|
||||
data: null
|
||||
timeout: 10000
|
||||
responseType: ''
|
||||
response:
|
||||
fields: [loading, ok, status, error, headers, responseType, result]
|
||||
result:
|
||||
behavior: Latest response result.
|
||||
|
||||
methods:
|
||||
do:
|
||||
signature: api.do(options)
|
||||
behavior: Merge options with request and perform HTTP.request.
|
||||
options:
|
||||
noui: Suppress automatic error toast.
|
||||
|
||||
events:
|
||||
response:
|
||||
detail: Complete HTTP response object.
|
||||
error:
|
||||
detail: Error object.
|
||||
|
||||
rules:
|
||||
- request.url is required before calling do.
|
||||
- Bind request with $.request when external state owns request configuration.
|
||||
|
||||
examples:
|
||||
auto: |
|
||||
<script>
|
||||
const usersRequest = { url: '/api/users', method: 'GET' }
|
||||
</script>
|
||||
<API id="usersApi" auto $.request="usersRequest"></API>
|
||||
<List $.state.list="usersApi.result.list"></List>
|
||||
do: |
|
||||
<script>
|
||||
const response = await usersApi.do({ url: '/api/users', method: 'GET', noui: true })
|
||||
if (response.ok) console.log(response.result)
|
||||
</script>
|
||||
|
||||
tests:
|
||||
- API.test.html
|
||||
134
components/AutoForm.js
Normal file
134
components/AutoForm.js
Normal file
@ -0,0 +1,134 @@
|
||||
/**
|
||||
* AutoForm Core Component
|
||||
*/
|
||||
|
||||
const AUTOFORM_BLUEPRINT = globalThis.Util.makeDom(/*html*/`
|
||||
<div class="auto-form-root">
|
||||
<form $class="\${this.inline ? 'd-flex flex-wrap align-items-center gap-3' : (this.vertical ? 'd-flex flex-column' : (this.horizontal ? 'auto-grid-form forced-horizontal' : 'auto-grid-form'))}" $onsubmit="event.stopPropagation();event.preventDefault();this.submit()">
|
||||
|
||||
<template $each="this.state.schema || []">
|
||||
<template $$if="item.if || 'true'">
|
||||
<div style="display:contents">
|
||||
<label $name="item.name" $class="\${this.inline ? 'mb-0 text-muted text-nowrap' : 'col-form-label text-muted'}" $text="item.label"></label>
|
||||
<div control-wrapper $class="\${this.inline ? 'd-flex align-items-center' : 'mb-3 d-flex align-items-center'}">
|
||||
<template $if="['text', 'password', 'email', 'number', 'date', 'datetime', 'file'].includes(item.type)"><input $name="item.name" $type="item.type" $.="item.setting || {}" $bind="this.state.data[item.name]" $class="form-control \${item.type === 'number' ? 'text-end' : ''}"></template>
|
||||
|
||||
<template $if="item.type === 'select'"><select $name="item.name" $.="item.setting || {}" $bind="this.state.data[item.name]" class="form-select">
|
||||
<option value="" $if="item.placeholder" $text="item.placeholder" disabled selected></option>
|
||||
<option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option>
|
||||
</select></template>
|
||||
|
||||
<template $if="['checkbox', 'radio'].includes(item.type)"><div class="d-flex align-items-center flex-wrap gap-3 h-100" style="padding: 0 0.75rem; min-height: calc(2.25rem + 2px);">
|
||||
<label $each="item.options || [item.text||item.label||item.name]" as="opt" class="form-check mb-0 d-flex align-items-center" style="padding-left:0; cursor:pointer;">
|
||||
<input $name="item.name" class="form-check-input m-0 me-2" style="float:none;" $type="item.type" $.="item.setting || {}" $value="item.options ? (opt.value !== undefined ? opt.value : opt) : 'on'" $bind="this.state.data[item.name]">
|
||||
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="opt.label || opt" class="form-check-label"></span>
|
||||
</label>
|
||||
</div></template>
|
||||
|
||||
<template $if="item.type === 'switch'"><div class="form-check form-switch fs-5 d-flex align-items-center m-0" style="padding: 0 0.75rem; min-height: calc(2.25rem + 2px); display:flex !important;">
|
||||
<input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" type="checkbox" $bind="this.state.data[item.name]">
|
||||
</div></template>
|
||||
|
||||
<template $if="item.type === 'textarea'"><textarea $name="item.name" class="form-control" $.="item.setting || {}" $bind="this.state.data[item.name]"></textarea></template>
|
||||
</div>
|
||||
</div>
|
||||
</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 slot-id="actions"></div>
|
||||
<button $if="!this.nobutton" type="submit" class="btn btn-primary" $text="this.submitlabel || '{#Submit#}'"></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`)
|
||||
|
||||
const AUTOFORM_STYLE = globalThis.Util.makeDom(/*html*/`<style>
|
||||
.auto-grid-form { display: block; }
|
||||
.auto-grid-form .col-form-label { text-align: left; margin-bottom: 0.25rem; padding-top: 0; }
|
||||
|
||||
@media (min-width: 576px) {
|
||||
.auto-grid-form { display: grid; grid-template-columns: max-content 1fr; gap: 0 1.5rem; }
|
||||
.auto-grid-form .col-form-label { text-align: right; margin-bottom: 1rem; padding-right: 0; max-width: 200px; padding-top: calc(0.375rem + 1px); }
|
||||
.auto-grid-form [control-wrapper] { min-height: calc(2.25rem + 2px); }
|
||||
}
|
||||
|
||||
.auto-grid-form.forced-horizontal { display: grid !important; grid-template-columns: max-content 1fr !important; gap: 0 1.5rem !important; }
|
||||
.auto-grid-form.forced-horizontal .col-form-label { text-align: right !important; margin-bottom: 1rem !important; padding-right: 0 !important; max-width: 200px !important; padding-top: calc(0.375rem + 1px) !important; }
|
||||
.auto-grid-form.forced-horizontal [control-wrapper] { min-height: calc(2.25rem + 2px) !important; }
|
||||
|
||||
.auto-form-root .form-check-input { width: 1.2em; height: 1.2em; }
|
||||
.auto-form-root .form-switch .form-check-input { width: 2em; }
|
||||
</style>`)
|
||||
|
||||
globalThis.Component.register('AutoForm', container => {
|
||||
if (!container.state.schema) container.state.schema = []
|
||||
|
||||
const ensureProxy = v => (v && typeof v === 'object' && !v.__isProxy) ? globalThis.NewState(v) : v;
|
||||
const setData = value => {
|
||||
const data = ensureProxy(value || {})
|
||||
container.data = data
|
||||
if (container.state.data !== data) container.state.data = data
|
||||
}
|
||||
container.state.__watch('data', setData)
|
||||
setData(container.state.data)
|
||||
|
||||
container.vertical = container.hasAttribute('vertical')
|
||||
container.horizontal = container.hasAttribute('horizontal')
|
||||
container.inline = container.hasAttribute('inline')
|
||||
container.nobutton = container.hasAttribute('nobutton')
|
||||
container.submitlabel = container.getAttribute('submitlabel') || ''
|
||||
container.request = { method: 'POST' }
|
||||
container.response = {}
|
||||
container.result = null
|
||||
|
||||
container.form = globalThis.$(container, 'form')
|
||||
container.submit = (opt = {}) => {
|
||||
if (!container.form.reportValidity()) return globalThis.Toast?.show('{#verify failed#}', { type: 'danger' })
|
||||
if (!container.dispatchEvent(new CustomEvent('submit', { detail: container.data, cancelable: true, bubbles: false }))) return
|
||||
const req = { ...container.request, data: container.data, noui: true, ...opt }
|
||||
let task = null
|
||||
if (container.api) task = container.api.do(req)
|
||||
else if (container.request.url) task = globalThis.HTTP.request(req)
|
||||
else return console.warn('{#please config .api or .request.url to auto submit#}')
|
||||
task.then(resp => {
|
||||
container.response = resp
|
||||
container.result = resp.result
|
||||
if (typeof resp.result === 'object' && resp.result.error) throw new Error(resp.result.error)
|
||||
container.dispatchEvent(new CustomEvent('response', { detail: resp, bubbles: false }))
|
||||
}).catch(err => {
|
||||
globalThis.Toast?.show(err.message, { type: 'danger' })
|
||||
container.dispatchEvent(new CustomEvent('error', { detail: err, bubbles: true }))
|
||||
})
|
||||
}
|
||||
|
||||
}, AUTOFORM_BLUEPRINT, AUTOFORM_STYLE)
|
||||
|
||||
const findAnchorInBlueprint = (root) => {
|
||||
let f = root.querySelector('[control-wrapper]');
|
||||
if (f) return f;
|
||||
for (const t of root.querySelectorAll('template')) {
|
||||
f = findAnchorInBlueprint(t.content); if (f) return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const AutoForm = {
|
||||
customTypes: [],
|
||||
register: (name, typeName) => {
|
||||
const type = typeName || name
|
||||
if (!AutoForm.customTypes.find(t => t.name === name)) {
|
||||
AutoForm.customTypes.push({ name, typeName: type })
|
||||
AutoForm._addAutoFormComponent(name, type)
|
||||
}
|
||||
},
|
||||
_addAutoFormComponent: (name, type) => {
|
||||
const wrapper = findAnchorInBlueprint(AUTOFORM_BLUEPRINT)
|
||||
if (wrapper) {
|
||||
const node = globalThis.Util.makeDom(`<template $if="item.type?.toLowerCase() === '${type.toLowerCase()}'"><${name} $name="item.name" $.="item.setting || {}" $bind="thisNode.closest('AutoForm').data[item.name]" class="w-100"></${name}></template>`)
|
||||
wrapper.appendChild(node)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.AutoForm = AutoForm;
|
||||
1
components/AutoForm.min.js
vendored
Normal file
1
components/AutoForm.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
83
components/AutoForm.test.html
Normal file
83
components/AutoForm.test.html
Normal file
@ -0,0 +1,83 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>components.form.AutoForm tests</title>
|
||||
<script src="../ui.js" components="API,AutoForm,DatePicker,ColorPicker,IconPicker,TagsInput"></script>
|
||||
<script>
|
||||
const profile = {
|
||||
name: 'Ada', password: 'secret', email: 'ada@example.test', age: 36,
|
||||
birthday: '1815-12-10', appointment: '1843-07-01T09:00', role: 'admin',
|
||||
alerts: true, plan: 'pro', active: true, bio: 'First programmer.',
|
||||
periodStart: '1843-07-01', periodEnd: '1843-07-31', color: '#0d6efd', icon: 'star', tags: ['math']
|
||||
}
|
||||
const profileSchema = [
|
||||
{ name: 'name', label: 'Name', type: 'text', setting: { required: true }, placeholder: 'Name' },
|
||||
{ name: 'password', label: 'Password', type: 'password' },
|
||||
{ name: 'email', label: 'Email', type: 'email' },
|
||||
{ name: 'age', label: 'Age', type: 'number' },
|
||||
{ name: 'birthday', label: 'Birthday', type: 'date' },
|
||||
{ name: 'appointment', label: 'Appointment', type: 'datetime' },
|
||||
{ name: 'avatar', label: 'Avatar', type: 'file' },
|
||||
{ name: 'role', label: 'Role', type: 'select', options: [{ value: 'admin', label: 'Admin' }, { value: 'user', label: 'User' }] },
|
||||
{ name: 'alerts', label: 'Alerts', type: 'checkbox', text: 'Receive alerts' },
|
||||
{ name: 'plan', label: 'Plan', type: 'radio', options: ['free', 'pro'] },
|
||||
{ name: 'active', label: 'Active', type: 'switch' },
|
||||
{ name: 'bio', label: 'Bio', type: 'textarea' },
|
||||
{ name: 'periodStart', label: 'Period', type: 'DatePicker', setting: { rangeEnd: 'periodEnd' } },
|
||||
{ name: 'color', label: 'Color', type: 'ColorPicker' },
|
||||
{ name: 'icon', label: 'Icon', type: 'IconPicker' },
|
||||
{ name: 'tags', label: 'Tags', type: 'TagsInput' },
|
||||
{ name: 'hidden', label: 'Hidden', type: 'text', if: "this.data.role === 'user'" }
|
||||
]
|
||||
const compactSchema = [{ name: 'name', label: 'Name', type: 'text' }]
|
||||
const saveRequest = { url: '/__http__/json', method: 'POST' }
|
||||
const errorRequest = { url: '/__http__/api-error', method: 'POST' }
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<API id="saveApi" $.request="saveRequest"></API>
|
||||
<API id="errorApi" $.request="errorRequest"></API>
|
||||
<AutoForm id="profileForm" $.state.data="profile" $.state.schema="profileSchema"></AutoForm>
|
||||
<AutoForm id="verticalForm" vertical nobutton $.state.data="profile" $.state.schema="compactSchema"><div slot="actions" $onclick="this.submit()">Save</div></AutoForm>
|
||||
<AutoForm id="horizontalForm" horizontal submitlabel="Update" $.state.data="profile" $.state.schema="compactSchema"></AutoForm>
|
||||
<AutoForm id="inlineForm" inline $.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="cancelForm" nobutton $.api="saveApi" $.state.data="profile" $.state.schema="compactSchema"></AutoForm>
|
||||
<button id="runAll" type="button">测试全部</button>
|
||||
<pre id="result"></pre>
|
||||
<script>
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 80))
|
||||
const cases = ['data', 'all-controls', 'setting', 'options', 'if', 'custom-type', 'vertical', 'horizontal', 'inline', 'nobutton', 'submitlabel', 'slot', 'submit', 'submit-event', 'response-event', 'error-event']
|
||||
const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
|
||||
window.runTest = async () => {
|
||||
const testResult = { name: 'components.form.AutoForm', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }
|
||||
await tick()
|
||||
const form = document.querySelector('#profileForm')
|
||||
assert(testResult, 'data', form.data.name === 'Ada' && form.querySelector('input[name="name"]').value === 'Ada', 'data initializes form controls')
|
||||
const standardTypes = ['text', 'password', 'email', 'number', 'date', 'datetime', 'file', 'select', 'checkbox', 'radio', 'switch', 'textarea']
|
||||
const customTypes = ['DatePicker', 'ColorPicker', 'IconPicker', 'TagsInput']
|
||||
assert(testResult, 'all-controls', form.querySelectorAll('[control-wrapper]').length === 16 && standardTypes.every(type => form.querySelector(`[name="${profileSchema.find(item => item.type === type).name}"]`)) && customTypes.every(type => form.querySelector(type)), 'one schema renders every documented standard and custom control type')
|
||||
assert(testResult, 'setting', form.querySelector('input[name="name"]').required, 'setting attributes are copied to controls')
|
||||
assert(testResult, 'options', form.querySelectorAll('[name="role"] option').length === 2, 'select options render schema options')
|
||||
assert(testResult, 'if', !form.querySelector('input[name="hidden"]'), '$$if hides fields whose expression is false')
|
||||
form.data.role = 'user'; await tick(); assert(testResult, 'if', !!form.querySelector('input[name="hidden"]'), '$$if evaluates item.if again after form data changes'); form.data.role = 'admin'
|
||||
assert(testResult, 'custom-type', !!form.querySelector('TagsInput'), 'registered AutoForm component type renders its component')
|
||||
assert(testResult, 'vertical', document.querySelector('#verticalForm form').classList.contains('flex-column'), 'vertical applies vertical layout')
|
||||
assert(testResult, 'horizontal', document.querySelector('#horizontalForm form').classList.contains('forced-horizontal'), 'horizontal applies horizontal layout')
|
||||
assert(testResult, 'inline', document.querySelector('#inlineForm form').classList.contains('flex-wrap'), 'inline applies inline layout')
|
||||
assert(testResult, 'nobutton', !document.querySelector('#verticalForm [type="submit"]') && !document.querySelector('#saveForm [type="submit"]'), 'nobutton hides the default submit button')
|
||||
assert(testResult, 'submitlabel', document.querySelector('#horizontalForm [type="submit"]').textContent === 'Update', 'submitlabel changes default button text')
|
||||
assert(testResult, 'slot', document.querySelector('#verticalForm').textContent.includes('Save'), 'actions slot renders custom controls')
|
||||
let submitEvent; let responseEvent; document.querySelector('#saveForm').addEventListener('submit', event => { submitEvent = event.detail }, { once: true }); document.querySelector('#saveForm').addEventListener('response', event => { responseEvent = event.detail }, { once: true }); document.querySelector('#saveForm').submit(); await tick()
|
||||
assert(testResult, 'submit', document.querySelector('#saveForm').result?.method === 'POST', 'submit sends data through the configured API')
|
||||
assert(testResult, 'submit-event', submitEvent?.name === 'Ada', 'submit event is cancelable and exposes form data')
|
||||
assert(testResult, 'response-event', responseEvent?.ok === true, 'response event exposes the HTTP response')
|
||||
let errorEvent; document.querySelector('#errorForm').addEventListener('error', event => { errorEvent = event.detail }, { once: true }); document.querySelector('#errorForm').submit(); await tick(); assert(testResult, 'error-event', errorEvent instanceof Error && errorEvent.message === 'application failed', 'error event exposes Error detail')
|
||||
testResult.coverage.tested = [...cases]; testResult.coverage.percent = 100; testResult.status = testResult.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult
|
||||
}
|
||||
document.querySelector('#runAll').onclick = window.runTest
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
65
components/AutoForm.yaml
Normal file
65
components/AutoForm.yaml
Normal file
@ -0,0 +1,65 @@
|
||||
name: AutoForm
|
||||
purpose: Render and submit a form from a schema array.
|
||||
|
||||
attributes:
|
||||
vertical: boolean
|
||||
horizontal: boolean
|
||||
inline: boolean
|
||||
nobutton: boolean
|
||||
submitlabel: string
|
||||
|
||||
state:
|
||||
data: object; form values, keyed by schema.name
|
||||
schema: array; field definitions
|
||||
|
||||
properties:
|
||||
api: API element used by submit when present.
|
||||
request: HTTP request defaults used when api is absent.
|
||||
response: Latest full HTTP response after submit.
|
||||
result: Latest response.result after submit.
|
||||
|
||||
schema:
|
||||
item: "{name, type, label?, setting?, options?, placeholder?, if?}"
|
||||
types: "text|password|email|number|date|datetime|file|select|checkbox|radio|switch|textarea|DatePicker|ColorPicker|IconPicker|TagsInput"
|
||||
options: "select/checkbox/radio options: ['A', {value:'b', label:'B'}]"
|
||||
setting: "attributes copied to the control; DatePicker rangeEnd is an example"
|
||||
if: 'JavaScript expression evaluated by `$$if`; use component data through `this.data`, for example `this.data.role === "admin"`.'
|
||||
|
||||
submit:
|
||||
source: Set api to an API element, or set request.url.
|
||||
method: form.submit(options); options override request defaults.
|
||||
events: "submit (cancelable, detail=data), response (detail=response), error (detail=Error)"
|
||||
result: "response is full HTTP response; result is response.result"
|
||||
|
||||
examples:
|
||||
basic: |
|
||||
<script>
|
||||
const profile = { name: 'Ada', role: 'admin', tags: ['math'] }
|
||||
const profileSchema = [
|
||||
{ name: 'name', label: 'Name', type: 'text', setting: { required: true } },
|
||||
{ name: 'role', label: 'Role', type: 'select', options: [{ value: 'admin', label: 'Admin' }, { value: 'user', label: 'User' }] },
|
||||
{ name: 'tags', label: 'Tags', type: 'TagsInput' }
|
||||
]
|
||||
</script>
|
||||
<AutoForm $.state.data="profile" $.state.schema="profileSchema"></AutoForm>
|
||||
submit_api: |
|
||||
<script>
|
||||
const profile = { name: 'Ada' }
|
||||
const profileSchema = [{ name: 'name', label: 'Name', type: 'text' }]
|
||||
</script>
|
||||
<API id="saveApi" $.request="{ url: '/api/profile', method: 'POST' }"></API>
|
||||
<AutoForm id="form" $.api="saveApi" $.state.data="profile" $.state.schema="profileSchema" $onresponse="console.log(event.detail.result)"></AutoForm>
|
||||
custom_actions: |
|
||||
<AutoForm id="form" nobutton $.state.data="profile" $.state.schema="profileSchema">
|
||||
<div slot="actions" $onclick="this.submit()">Save</div>
|
||||
</AutoForm>
|
||||
|
||||
related:
|
||||
- ../API.yaml
|
||||
- DatePicker.yaml
|
||||
- ColorPicker.yaml
|
||||
- IconPicker.yaml
|
||||
- TagsInput.yaml
|
||||
|
||||
tests:
|
||||
- AutoForm.test.html
|
||||
42
components/DataChart.yaml
Normal file
42
components/DataChart.yaml
Normal file
@ -0,0 +1,42 @@
|
||||
name: DataChart
|
||||
|
||||
constructor:
|
||||
signature: new DataChart(canvas, options)
|
||||
options:
|
||||
type: line, bar, or pie. Default is line.
|
||||
data: Chart.js data object or source object array.
|
||||
options: Chart.js options object.
|
||||
map: "{ labels, values, label } for source object arrays."
|
||||
|
||||
component:
|
||||
attributes:
|
||||
type: line, bar, or pie.
|
||||
state:
|
||||
data: Chart.js data object or source object array.
|
||||
options: Chart.js options.
|
||||
map: "{ labels, values, label }"
|
||||
property:
|
||||
chartInstance: Underlying DataChart instance.
|
||||
|
||||
methods:
|
||||
update: Update optional data and redraw.
|
||||
destroy: Destroy the underlying Chart.js instance.
|
||||
|
||||
rules:
|
||||
- Give the DataChart container an explicit height.
|
||||
- Use map only when data is an array of objects.
|
||||
- DataChart destroys its Chart.js instance when the component unloads.
|
||||
|
||||
example: |
|
||||
<DataChart id="sales" type="bar"></DataChart>
|
||||
<script>
|
||||
const chart = document.querySelector('#sales')
|
||||
chart.state.data = [
|
||||
{ month: 'Jan', amount: 12 },
|
||||
{ month: 'Feb', amount: 18 }
|
||||
]
|
||||
chart.state.map = { labels: 'month', values: 'amount' }
|
||||
</script>
|
||||
|
||||
tests:
|
||||
- apigo.cc/web/chart/test/all.spec.js
|
||||
76
components/DataTable.yaml
Normal file
76
components/DataTable.yaml
Normal file
@ -0,0 +1,76 @@
|
||||
name: DataTable
|
||||
|
||||
attributes:
|
||||
editable:
|
||||
type: boolean
|
||||
behavior: Enable row, field, cell, and save controls.
|
||||
|
||||
state:
|
||||
fields: Array of column definitions; assign before list.
|
||||
list: Array of row objects; each key matches a field id.
|
||||
_originalList: Internal unfiltered row snapshot.
|
||||
sortConfig: "{ fieldId, direction }"
|
||||
filterConfig: Per-field filter configuration.
|
||||
selectedRowCount: Selected row count.
|
||||
isDirty: Whether edits are pending save.
|
||||
|
||||
field:
|
||||
required: [id, name]
|
||||
fields:
|
||||
id: Unique column id and row value key.
|
||||
name: Header label.
|
||||
type: Source value type.
|
||||
memo: Field description.
|
||||
settings: "{ width, pinned, formType, options, decimals, prefix, suffix, thousandSep, labelOn, labelOff }"
|
||||
formatter: Function receiving value and field.
|
||||
|
||||
methods:
|
||||
addRow: Add an empty row.
|
||||
deleteSelectedRow: Delete selected rows.
|
||||
saveChanges: Dispatch save.
|
||||
addField: Add a field.
|
||||
editField: Edit the active field.
|
||||
deleteField: Delete the active field.
|
||||
editCell: Open a cell editor.
|
||||
applySortFilter: Apply current or supplied sorting and filters.
|
||||
|
||||
events:
|
||||
save:
|
||||
detail: "{ list, fields }"
|
||||
savefields:
|
||||
detail: fields
|
||||
remove:
|
||||
detail: "{ items }"
|
||||
|
||||
global:
|
||||
DataTable:
|
||||
methods:
|
||||
registerFieldType: Register a field type configuration.
|
||||
getFieldTypes: Return registered field types.
|
||||
|
||||
related:
|
||||
- AutoForm.yaml
|
||||
- ../utilities/VirtualScroll.yaml
|
||||
- Resizer.yaml
|
||||
|
||||
rules:
|
||||
- Bind fields and list with $.state.fields and $.state.list.
|
||||
- Treat underscore-prefixed state fields as internal read-only diagnostics.
|
||||
- Custom field editors must be registered in AutoForm before use.
|
||||
|
||||
example: |
|
||||
<DataTable id="orders" editable></DataTable>
|
||||
<script>
|
||||
const table = document.querySelector('#orders')
|
||||
table.state.fields = [
|
||||
{ id: 'name', name: 'Name', type: 'text' },
|
||||
{ id: 'total', name: 'Total', type: 'number' }
|
||||
]
|
||||
table.state.list = [{ name: 'Ada', total: 42 }]
|
||||
table.addEventListener('save', event => saveRows(event.detail.list))
|
||||
</script>
|
||||
|
||||
tests:
|
||||
- apigo.cc/web/dataTable/test/all.spec.js
|
||||
- apigo.cc/web/dataTable/test/correctness.spec.js
|
||||
- apigo.cc/web/dataTable/test/validation.spec.js
|
||||
47
components/Dialog.js
Normal file
47
components/Dialog.js
Normal file
@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Dialog Component Module
|
||||
*/
|
||||
globalThis.Component.register('Dialog', globalThis.Component.getSetupFunction('Modal'), globalThis.Util.makeDom(/*html*/`
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content border-\${this.state?.type || 'primary'} border-2 shadow-lg">
|
||||
<template $if="this.state?.title">
|
||||
<div $class="modal-header py-2 px-3 bg-light fw-bold text-\${this.state?.type || 'primary'}" $text="this.state?.title"></div>
|
||||
</template>
|
||||
<div class="modal-body p-4 text-center">
|
||||
<div $text="this.state?.message" class="fs-5 text-secondary"></div>
|
||||
</div>
|
||||
<div class="modal-footer py-2 px-3 bg-light border-0 d-flex justify-content-center gap-3">
|
||||
<template $each="this.state?.buttons">
|
||||
<button type="button" $class="btn btn-\${this.state?.type} px-4" data-bs-dismiss="modal" $onclick="this.result=index+1" $text="item"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
let _dialogCount = 0
|
||||
globalThis.Dialog = {
|
||||
show({ title = '', message = '', buttons = ['{#Close#}'], type = 'body' }) {
|
||||
const d = document.body.appendChild(document.createElement('Dialog'))
|
||||
d.style.zIndex = 2000 + ++_dialogCount
|
||||
Promise.resolve().then(() => {
|
||||
Object.assign(d.state, { message, title, type, buttons })
|
||||
d.show()
|
||||
})
|
||||
return new Promise((resolve) => {
|
||||
d.addEventListener('change', e => {
|
||||
_dialogCount--
|
||||
resolve(d.result || 0)
|
||||
d.remove()
|
||||
})
|
||||
})
|
||||
},
|
||||
alert(message, options = {}) {
|
||||
return this.show({ message, ...options })
|
||||
},
|
||||
confirm(message, options = {}) {
|
||||
return new Promise(resolve => this.show({ message, buttons: ['{#Cancel#}', '{#Confirm#}'], ...options }).then(index => resolve(index >= 2)).catch(() => resolve(false)))
|
||||
}
|
||||
}
|
||||
1
components/Dialog.min.js
vendored
Normal file
1
components/Dialog.min.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
globalThis.Component.register("Dialog",globalThis.Component.getSetupFunction("Modal"),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 <template $if="this.state?.title">\n <div $class="modal-header py-2 px-3 bg-light fw-bold text-${this.state?.type || \'primary\'}" $text="this.state?.title"></div>\n </template>\n <div class="modal-body p-4 text-center">\n <div $text="this.state?.message" class="fs-5 text-secondary"></div>\n </div>\n <div class="modal-footer py-2 px-3 bg-light border-0 d-flex justify-content-center gap-3">\n <template $each="this.state?.buttons">\n <button type="button" $class="btn btn-${this.state?.type} px-4" data-bs-dismiss="modal" $onclick="this.result=index+1" $text="item"></button>\n </template>\n </div>\n </div>\n </div>\n</div>\n'));let _dialogCount=0;globalThis.Dialog={show({title:t="",message:e="",buttons:s=["{#Close#}"],type:a="body"}){const o=document.body.appendChild(document.createElement("Dialog"));return o.style.zIndex=2e3+ ++_dialogCount,Promise.resolve().then(()=>{Object.assign(o.state,{message:e,title:t,type:a,buttons:s}),o.show()}),new Promise(t=>{o.addEventListener("change",e=>{_dialogCount--,t(o.result||0),o.remove()})})},alert(t,e={}){return this.show({message:t,...e})},confirm(t,e={}){return new Promise(s=>this.show({message:t,buttons:["{#Cancel#}","{#Confirm#}"],...e}).then(t=>s(t>=2)).catch(()=>s(!1)))}};
|
||||
21
components/Dialog.yaml
Normal file
21
components/Dialog.yaml
Normal file
@ -0,0 +1,21 @@
|
||||
name: Dialog
|
||||
|
||||
purpose: Modal-based alert and confirmation dialog.
|
||||
|
||||
methods:
|
||||
Dialog.show: Show a custom dialog and resolve with its button index.
|
||||
Dialog.alert: Show a message and resolve after closing.
|
||||
Dialog.confirm: Show Cancel/Confirm and resolve to boolean.
|
||||
|
||||
state:
|
||||
title: Dialog title.
|
||||
message: Dialog body text.
|
||||
buttons: Button labels.
|
||||
type: Bootstrap contextual type.
|
||||
|
||||
examples:
|
||||
alert: "Dialog.alert('Saved successfully')"
|
||||
confirm: "Dialog.confirm('Delete this record?').then(ok => { if (ok) remove() })"
|
||||
|
||||
example: |
|
||||
Dialog.confirm('Delete this record?').then(ok => { if (ok) remove() })
|
||||
261
components/List.js
Normal file
261
components/List.js
Normal file
@ -0,0 +1,261 @@
|
||||
/**
|
||||
* List Component Module
|
||||
*/
|
||||
globalThis.Component.register('List', container => {
|
||||
container.mode = container.getAttribute('mode') || 'normal'
|
||||
container.fast = container.hasAttribute('fast')
|
||||
container.collapsible = container.hasAttribute('collapsible')
|
||||
|
||||
const padTopEl = container.fast ? container.querySelector('.vs-pad-top') : null
|
||||
const padBottomEl = container.fast ? container.querySelector('.vs-pad-bottom') : null
|
||||
|
||||
const defaultSets = {
|
||||
idfield: 'id', labelfield: 'label', summaryfield: 'summary',
|
||||
groupidfield: 'id', grouplabelfield: 'label', groupsummaryfield: 'summary', groupfield: 'group',
|
||||
parentfield: 'parent', groupicon: 'folder', itemicon: 'file'
|
||||
}
|
||||
// Field names are ordinary declarative attributes, so static markup needs no setup script.
|
||||
Object.keys(defaultSets).forEach(name => {
|
||||
if (container.hasAttribute(name)) container[name] = container.getAttribute(name)
|
||||
})
|
||||
container.collapsed = globalThis.NewState({})
|
||||
container.state.renderedList = []
|
||||
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.selectedItem = e.detail
|
||||
})
|
||||
|
||||
const matchString = (val, query) => {
|
||||
if (query === undefined || query === null || query === '') return true
|
||||
return String(val || '').toLowerCase().includes(String(query).toLowerCase())
|
||||
}
|
||||
|
||||
const matchesItem = (item, filter, filterFunc) => {
|
||||
if (filterFunc && !filterFunc(item)) return false
|
||||
if (filter === 0) filter = ''
|
||||
|
||||
if (filter !== undefined && filter !== null && filter !== '') {
|
||||
if (typeof filter === 'object') {
|
||||
for (const [key, qVal] of Object.entries(filter)) {
|
||||
if (qVal !== undefined && qVal !== null && qVal !== '') {
|
||||
if (!matchString(item[key], qVal)) return false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const q = String(filter).trim()
|
||||
if (q) {
|
||||
const labelMatches = matchString(item[container.labelfield], q)
|
||||
const summaryMatches = matchString(item[container.summaryfield], q)
|
||||
if (!labelMatches && !summaryMatches) return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const updateFlatList = () => {
|
||||
globalThis.Util.updateDefaults(container, defaultSets)
|
||||
let list = container.state.list || []
|
||||
let filter = container.state.filter
|
||||
let order = container.state.order
|
||||
const filterFunc = container._filterFunc
|
||||
const orderFunc = container._orderFunc
|
||||
|
||||
if (filter === 0) filter = ''
|
||||
if (order === 0) order = ''
|
||||
|
||||
if (orderFunc) {
|
||||
list = [...list].sort(orderFunc)
|
||||
} else if (order) {
|
||||
if (typeof order === 'string') {
|
||||
const parts = order.trim().split(/\s+/)
|
||||
const field = parts[0]
|
||||
const isDesc = parts[1]?.toLowerCase() === 'desc' || field.startsWith('-')
|
||||
const cleanField = field.startsWith('-') ? field.slice(1) : field
|
||||
list = [...list].sort((a, b) => {
|
||||
const valA = a[cleanField]
|
||||
const valB = b[cleanField]
|
||||
if (valA === undefined || valA === null) return isDesc ? 1 : -1
|
||||
if (valB === undefined || valB === null) return isDesc ? -1 : 1
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
return isDesc ? valB.localeCompare(valA) : valA.localeCompare(valB)
|
||||
}
|
||||
return isDesc ? (valB > valA ? 1 : -1) : (valA > valB ? 1 : -1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const flatList = []
|
||||
if (container.mode === 'group') {
|
||||
const filteredList = list.filter(item => matchesItem(item, filter, filterFunc))
|
||||
const itemMap = {}
|
||||
filteredList.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
|
||||
(container.state.groups || []).forEach(group => {
|
||||
const items = itemMap[group[container.groupidfield]]
|
||||
let groupMatches = false
|
||||
if (filter !== undefined && filter !== null && filter !== '') {
|
||||
if (typeof filter === 'object') {
|
||||
groupMatches = true
|
||||
for (const [key, qVal] of Object.entries(filter)) {
|
||||
if (qVal !== undefined && qVal !== null && qVal !== '') {
|
||||
if (!matchString(group[key], qVal)) {
|
||||
groupMatches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const q = String(filter).trim()
|
||||
if (q) {
|
||||
groupMatches = matchString(group[container.grouplabelfield], q) ||
|
||||
matchString(group[container.groupsummaryfield], q)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ((items && items.length > 0) || groupMatches || (!filter && !filterFunc)) {
|
||||
flatList.push({ type: 'group', ...group })
|
||||
if (items) items.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||
}
|
||||
})
|
||||
} else if (container.mode === 'tree') {
|
||||
const itemMap = {}
|
||||
list.forEach(item => { itemMap[item[container.idfield]] = item })
|
||||
|
||||
const matches = new Set()
|
||||
list.forEach(item => {
|
||||
if (matchesItem(item, filter, filterFunc)) {
|
||||
matches.add(item[container.idfield])
|
||||
}
|
||||
})
|
||||
|
||||
const keep = new Set(matches)
|
||||
if (filter || filterFunc) {
|
||||
matches.forEach(id => {
|
||||
let curr = itemMap[id]
|
||||
while (curr) {
|
||||
const parentId = curr[container.parentfield]
|
||||
if (!parentId) break
|
||||
if (keep.has(parentId)) break
|
||||
keep.add(parentId)
|
||||
curr = itemMap[parentId]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const childrenMap = {}
|
||||
list.forEach(item => {
|
||||
if ((!filter && !filterFunc) || keep.has(item[container.idfield])) {
|
||||
const parentId = item[container.parentfield] || ''
|
||||
; (childrenMap[parentId] ??= []).push(item)
|
||||
}
|
||||
})
|
||||
|
||||
const traverse = (items, level, parents) => items.forEach(item => {
|
||||
const id = item[container.idfield]
|
||||
const hasChildren = !!childrenMap[id]?.length
|
||||
const isCollapsed = container.collapsed[id]
|
||||
flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
||||
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id])
|
||||
})
|
||||
traverse(childrenMap[''] || [], 0, [])
|
||||
} else {
|
||||
const filteredList = list.filter(item => matchesItem(item, filter, filterFunc))
|
||||
filteredList.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||
}
|
||||
container.state.flatList = flatList
|
||||
}
|
||||
|
||||
container.state.__watch('list', updateFlatList)
|
||||
container.state.__watch('filter', updateFlatList)
|
||||
container.state.__watch('order', updateFlatList)
|
||||
const vs = container.fast ? globalThis.VirtualScroll() : null
|
||||
|
||||
let refreshing = false
|
||||
container.refresh = () => {
|
||||
if (!container.fast || refreshing) return
|
||||
refreshing = true
|
||||
try {
|
||||
const res = vs.calc(container, container.state.flatList)
|
||||
if (res) {
|
||||
if (padTopEl) padTopEl.style.height = `${res.prevHeight}px`
|
||||
if (padBottomEl) padBottomEl.style.height = `${res.postHeight}px`
|
||||
container.state.listStartIndex = res.listStartIndex
|
||||
container.state.renderedList = res.renderedList
|
||||
}
|
||||
} finally {
|
||||
setTimeout(() => { refreshing = false }, 0)
|
||||
}
|
||||
}
|
||||
|
||||
container.onItemUpdate = (index, node) => { if (container.fast) vs.update(index + (container.state.listStartIndex || 0), node) }
|
||||
|
||||
container.state.__watch('flatList', flatList => {
|
||||
if (container.fast) {
|
||||
if (padTopEl) padTopEl.style.height = '0px'
|
||||
if (padBottomEl) padBottomEl.style.height = '0px'
|
||||
container.state.listStartIndex = 0
|
||||
container.state.renderedList = vs.reset(flatList, container) || []
|
||||
setTimeout(() => { if (container.state.flatList === flatList) vs.init(flatList, container.refresh) })
|
||||
} else container.state.renderedList = flatList
|
||||
})
|
||||
|
||||
container.selectItem = (item, index) => {
|
||||
if (container.hasAttribute('auto-select')) {
|
||||
container.state.selectedItem = container.state.selectedItem === item[container.idfield] ? null : item[container.idfield]
|
||||
}
|
||||
container.dispatchEvent(new CustomEvent('change', { detail: container.state.selectedItem, bubbles: false }))
|
||||
container.dispatchEvent(new CustomEvent('itemclick', { bubbles: false, detail: { item, index: index + (container.fast ? (container.state.listStartIndex || 0) : 0) } }))
|
||||
}
|
||||
container.selectGroup = (item, index) => {
|
||||
if (container.hasAttribute('auto-select-group')) container.state.selectedGroup = container.state.selectedGroup === item[container.groupidfield] ? null : item[container.groupidfield]
|
||||
container.dispatchEvent(new CustomEvent('groupclick', { bubbles: false, detail: { item, index } }))
|
||||
}
|
||||
container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } }
|
||||
|
||||
Object.defineProperty(container, 'filterFunc', {
|
||||
get: () => container._filterFunc,
|
||||
set: v => { container._filterFunc = v; updateFlatList(); }
|
||||
})
|
||||
Object.defineProperty(container, 'orderFunc', {
|
||||
get: () => container._orderFunc,
|
||||
set: v => { container._orderFunc = v; updateFlatList(); }
|
||||
})
|
||||
|
||||
updateFlatList()
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="list-group overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
|
||||
<div class="vs-pad-top flex-shrink-0" style="height:0px;"></div>
|
||||
<template slot-id="item" $each="this.state.renderedList">
|
||||
<div $onupdate="this.onItemUpdate(index, thisNode)" $class="list-group-item d-inline-flex align-items-center pe-2 \${item.type==='group'?'bg-body-tertiary fw-bold ps-2':'list-group-item-action ' + (this.mode==='group'?'ps-4':'ps-2')} \${item.type==='group'?(this.state?.selectedGroup===item[this.groupidfield]?'active':''):(this.state?.selectedItem===item[this.idfield]?'active':'')}" $onclick="item.type==='group'?this.selectGroup(item,index):this.selectItem(item,index)">
|
||||
<template $if="item.type === 'group'">
|
||||
<template $if="this.groupicon">
|
||||
<span $class="bi bi-\${this.groupicon} text-body"></span>
|
||||
</template>
|
||||
<div class="flex-shrink-0 px-1" $text="\${item[this.grouplabelfield]}"></div>
|
||||
<div class="text-muted small flex-fill text-end" $text="\${item[this.groupsummaryfield] || ''}"></div>
|
||||
<div slot-id="group-actions"></div>
|
||||
</template>
|
||||
<template $if="item.type === 'item'">
|
||||
<template $if="this.mode === 'tree'">
|
||||
<div $style="width:\${item._level * 16 + (this.collapsible ? 16 : 0)}px; cursor:\${this.collapsible ? 'pointer' : 'default'}" class="text-end text-muted flex-shrink-0" $onclick="event.stopPropagation(); this.toggleCollapse(item)">
|
||||
<template $if="this.collapsible && item._hasChildren">
|
||||
<i $class="bi \${this.collapsed[item[this.idfield]] ? 'bi-caret-right-fill' : 'bi-caret-down-fill'}"></i>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template $if="this.mode === 'tree'">
|
||||
<span $class="text-muted bi bi-\${item._hasChildren ? this.groupicon : this.itemicon}"></span>
|
||||
</template>
|
||||
<template $if="this.mode !== 'tree' && this.itemicon">
|
||||
<span $class="bi bi-\${this.itemicon} text-body"></span>
|
||||
</template>
|
||||
<div class="flex-shrink-0 px-1" $text="\${item[this.labelfield] || ''}"></div>
|
||||
<div class="text-muted fs-7 small ms-2 flex-fill text-truncate text-end" $text="\${item[this.summaryfield] || ''}"></div>
|
||||
<div slot-id="item-actions"></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="vs-pad-bottom flex-shrink-0" style="height:0px;"></div>
|
||||
</div>
|
||||
`))
|
||||
1
components/List.min.js
vendored
Normal file
1
components/List.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
90
components/List.test.html
Normal file
90
components/List.test.html
Normal file
@ -0,0 +1,90 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>components.List tests</title>
|
||||
<script src="../ui.js" components="List"></script>
|
||||
<script>
|
||||
const users = Array.from({ length: 60 }, (_, index) => ({ id: index + 1, label: 'User ' + (index + 1), summary: index % 2 ? 'Editor' : 'Admin' }))
|
||||
const groups = [{ id: 'ops', label: 'Operations', summary: '20 members' }, { id: 'design', label: 'Design', summary: '20 members' }, { id: 'qa', label: 'QA', summary: '20 members' }]
|
||||
const groupedUsers = groups.flatMap(group => Array.from({ length: 20 }, (_, index) => ({ id: group.id + '-' + (index + 1), label: group.label + ' ' + (index + 1), summary: 'Member', group: group.id })))
|
||||
const treeUsers = [{ id: 'root', label: 'Root', summary: 'Parent node', parent: '' }, ...Array.from({ length: 20 }, (_, index) => ({ id: 'child-' + (index + 1), label: 'Child ' + (index + 1), summary: 'Tree child', parent: 'root' }))]
|
||||
const fastUsers = Array.from({ length: 1000 }, (_, index) => ({ id: index + 1, label: 'Virtual user ' + (index + 1), summary: 'Virtual scrolling row' }))
|
||||
const searchableUsers = Array.from({ length: 100 }, (_, index) => ({ id: index + 1, label: index === 49 ? 'Beta' : (index % 2 ? 'Alpha ' : 'Gamma ') + (index + 1), summary: 'Searchable row ' + (index + 1) }))
|
||||
const customUsers = [{ key: 'ada', title: 'Ada', desc: 'Admin' }, { key: 'lin', title: 'Lin', desc: 'Editor' }]
|
||||
State.nav = 'normal'
|
||||
</script>
|
||||
</head>
|
||||
<body class="vh-100 overflow-hidden bg-body-tertiary">
|
||||
<div class="container-fluid h-100 py-3 d-flex flex-column">
|
||||
<header class="d-flex align-items-center gap-2 mb-3 flex-shrink-0">
|
||||
<h1 class="h4 mb-0">components.List</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="text-body-secondary small"></span>
|
||||
</header>
|
||||
|
||||
<ul class="nav nav-tabs mb-3 flex-shrink-0">
|
||||
<li class="nav-item"><button class="nav-link" $class="nav-link ${State.nav === 'normal' ? 'active' : ''}" $onclick="State.nav = 'normal'">普通</button></li>
|
||||
<li class="nav-item"><button class="nav-link" $class="nav-link ${State.nav === 'actions' ? 'active' : ''}" $onclick="State.nav = 'actions'">Actions</button></li>
|
||||
<li class="nav-item"><button class="nav-link" $class="nav-link ${State.nav === 'group' ? 'active' : ''}" $onclick="State.nav = 'group'">分组</button></li>
|
||||
<li class="nav-item"><button class="nav-link" $class="nav-link ${State.nav === 'tree' ? 'active' : ''}" $onclick="State.nav = 'tree'">树形</button></li>
|
||||
<li class="nav-item"><button class="nav-link" $class="nav-link ${State.nav === 'fast' ? 'active' : ''}" $onclick="State.nav = 'fast'">虚拟滚动</button></li>
|
||||
<li class="nav-item"><button class="nav-link" $class="nav-link ${State.nav === 'filterSort' ? 'active' : ''}" $onclick="State.nav = 'filterSort'">过滤排序</button></li>
|
||||
<li class="nav-item"><button class="nav-link" $class="nav-link ${State.nav === 'fields' ? 'active' : ''}" $onclick="State.nav = 'fields'">自定义字段</button></li>
|
||||
</ul>
|
||||
|
||||
<main class="flex-fill overflow-hidden">
|
||||
<section class="h-100 d-flex flex-column" $if="State.nav === 'normal'"><List id="normalList" class="flex-fill border rounded bg-body" $.state.list="users" auto-select></List></section>
|
||||
|
||||
<section class="h-100 d-flex flex-column" $if="State.nav === 'actions'"><List id="actionsList" class="flex-fill border rounded bg-body" $.state.list="users" auto-select><button slot="item-actions" class="actions-action btn btn-sm btn-outline-secondary" $onclick="event.stopPropagation()">Edit</button></List></section>
|
||||
|
||||
<section class="h-100 d-flex flex-column" $if="State.nav === 'group'"><List id="groupList" class="flex-fill border rounded bg-body" mode="group" $.state.groups="groups" $.state.list="groupedUsers" auto-select-group></List></section>
|
||||
|
||||
<section class="h-100 d-flex flex-column" $if="State.nav === 'tree'"><List id="treeList" class="flex-fill border rounded bg-body" mode="tree" $.state.list="treeUsers" collapsible></List></section>
|
||||
|
||||
<section class="h-100 d-flex flex-column" $if="State.nav === 'fast'"><List id="fastList" class="flex-fill border rounded bg-body" $.state.list="fastUsers" fast></List></section>
|
||||
|
||||
<section class="h-100 d-flex flex-column" $if="State.nav === 'filterSort'">
|
||||
<h2 class="h5 flex-shrink-0">过滤与排序</h2>
|
||||
<List id="filterList" class="flex-fill border rounded bg-body order-2" $.state.list="searchableUsers" $.state.filter="'Beta'"></List>
|
||||
<div class="input-group mb-2 flex-shrink-0 order-1"><input id="filterInput" class="form-control" placeholder="输入过滤文字" $bind="filterList.state.filter"><select id="sortInput" class="form-select" $bind="filterList.state.order"><option value="">原始顺序</option><option value="label">标签升序</option><option value="-label">标签降序</option></select></div>
|
||||
</section>
|
||||
|
||||
<section class="h-100 d-flex flex-column" $if="State.nav === 'fields'"><List id="fieldList" class="flex-fill border rounded bg-body" idfield="key" labelfield="title" summaryfield="desc" $.state.list="customUsers"></List></section>
|
||||
</main>
|
||||
|
||||
<pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mt-3 mb-0 flex-shrink-0 small"></pre>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const cases = ['normal', 'actions', 'group', 'tree', 'fast', 'filterSort', 'fields']
|
||||
const featureMap = { normal: ['normal', 'select'], actions: ['actions'], group: ['group', 'group-select'], tree: ['tree', 'collapse'], fast: ['fast'], filterSort: ['filter', 'sort'], fields: ['custom-fields'] }
|
||||
const allFeatures = Object.values(featureMap).flat()
|
||||
const consoleErrors = []
|
||||
const originalConsoleError = console.error
|
||||
console.error = (...args) => { consoleErrors.push(args.map(String).join(' ')); originalConsoleError(...args) }
|
||||
addEventListener('error', event => consoleErrors.push(String(event.message || event.error || event)))
|
||||
addEventListener('unhandledrejection', event => consoleErrors.push(String(event.reason || event)))
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 80))
|
||||
const newResult = id => ({ name: 'components.List', ...(id ? { case: id } : {}), status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: allFeatures, percent: 0 } })
|
||||
const assert = (result, feature, condition, message) => { result.assertions++; if (condition) result.passed++; else { result.failed++; result.failures.push({ feature, message }) } }
|
||||
const currentList = id => document.querySelector('#' + ({ normal: 'normalList', actions: 'actionsList', group: 'groupList', tree: 'treeList', fast: 'fastList', filterSort: 'filterList', fields: 'fieldList' }[id]))
|
||||
async function verify(id, result) {
|
||||
State.nav = id; await tick(); const list = currentList(id)
|
||||
if (id === 'normal') { list.state.selectedItem = null; assert(result, 'normal', list.querySelectorAll('.list-group-item').length === 60, 'normal list renders 60 rows'); list.querySelector('.list-group-item').click(); assert(result, 'select', list.state.selectedItem === 1, 'row click selects item') }
|
||||
if (id === 'actions') { assert(result, 'actions', list.querySelectorAll('.actions-action').length === 60, 'actions render for every row'); list.querySelector('.actions-action').click(); assert(result, 'actions', list.state.selectedItem == null, 'action does not select row') }
|
||||
if (id === 'group') { list.state.selectedGroup = null; assert(result, 'group', list.querySelectorAll('.fw-bold').length === 3, 'three group headers render'); list.querySelector('.fw-bold').click(); assert(result, 'group-select', list.state.selectedGroup === 'ops', 'group click selects group') }
|
||||
if (id === 'tree') { list.collapsed.root = false; list.state.list = [...treeUsers]; await tick(); assert(result, 'tree', list.querySelectorAll('.list-group-item').length === 21, 'tree renders all nodes'); const toggle = list.querySelector('.bi-caret-down-fill'); assert(result, 'collapse', !!toggle, 'expanded root has a collapse control'); if (toggle) { toggle.click(); await tick(); assert(result, 'collapse', list.querySelectorAll('.list-group-item').length === 1, 'tree collapses children') } }
|
||||
if (id === 'fast') { assert(result, 'fast', list.querySelectorAll('.list-group-item').length < 1000, 'virtual list renders a window'); list.scrollTop = list.scrollHeight; list.dispatchEvent(new Event('scroll')); await tick(); assert(result, 'fast', list.scrollTop > 0, 'List itself scrolls') }
|
||||
if (id === 'filterSort') { assert(result, 'filter', list.querySelectorAll('.list-group-item').length === 1, 'Beta filter keeps one item'); filterInput.value = ''; filterInput.dispatchEvent(new Event('input')); sortInput.value = '-label'; sortInput.dispatchEvent(new Event('change')); await tick(); assert(result, 'sort', list.querySelector('.list-group-item')?.textContent.includes('Gamma'), 'descending order puts Gamma first') }
|
||||
if (id === 'fields') assert(result, 'custom-fields', list.textContent.includes('Ada') && list.textContent.includes('Admin'), 'custom field mapping renders title and desc')
|
||||
}
|
||||
function finish(testResult, ids) { testResult.coverage.tested = ids.flatMap(id => featureMap[id]); testResult.coverage.percent = Math.round(testResult.coverage.tested.length / allFeatures.length * 100); testResult.consoleErrors = [...consoleErrors]; testResult.status = testResult.failed || testResult.consoleErrors.length ? 'failed' : 'passed'; const statusNode = document.querySelector('#status'); const coverageNode = document.querySelector('#coverage'); statusNode.className = 'badge text-bg-' + (testResult.status === 'passed' ? 'success' : 'danger'); statusNode.textContent = testResult.status === 'passed' ? '通过' : '失败'; coverageNode.textContent = `覆盖 ${testResult.coverage.percent}%`; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult }
|
||||
window.runCase = async id => { consoleErrors.length = 0; const testResult = newResult(id); await verify(id, testResult); return finish(testResult, [id]) }
|
||||
window.runTest = async () => { consoleErrors.length = 0; const testResult = newResult(); for (const id of cases) await verify(id, testResult); return finish(testResult, cases) }
|
||||
document.querySelector('#runAll').onclick = window.runTest
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
80
components/List.yaml
Normal file
80
components/List.yaml
Normal file
@ -0,0 +1,80 @@
|
||||
name: List
|
||||
|
||||
attributes:
|
||||
mode: [normal|group|tree]
|
||||
fast: "boolean — virtual scrolling"
|
||||
collapsible: "boolean — collapsible tree nodes"
|
||||
auto-select: "boolean — clicked item id -> selectedItem"
|
||||
auto-select-group: "boolean — clicked group id -> selectedGroup"
|
||||
idfield: id
|
||||
labelfield: label
|
||||
summaryfield: summary
|
||||
groupidfield: id
|
||||
grouplabelfield: label
|
||||
groupsummaryfield: summary
|
||||
groupfield: group
|
||||
parentfield: parent
|
||||
|
||||
state:
|
||||
list: 'Array of item objects. Declarative form: `$.state.list="users"`.'
|
||||
groups: 'Array of group objects when mode=group. Declarative form: `$.state.groups="groups"`.'
|
||||
filter: String or object filter.
|
||||
order: Field name, `-name`, or `name desc`.
|
||||
selectedItem: Selected item id (written by auto-select).
|
||||
selectedGroup: Selected group id (written by auto-select-group).
|
||||
|
||||
properties:
|
||||
filterFunc: Custom item predicate.
|
||||
orderFunc: Custom sorting comparator.
|
||||
|
||||
events:
|
||||
change:
|
||||
detail: selectedItem
|
||||
itemclick:
|
||||
detail: "{ item, index }"
|
||||
groupclick:
|
||||
detail: "{ item, index }"
|
||||
|
||||
slots:
|
||||
item: Replace the complete item row.
|
||||
item-actions: Render at the right side of every item row.
|
||||
group-actions: Render at the right side of every group row.
|
||||
|
||||
rules:
|
||||
- With fast, a custom item row must retain list-group-item and $onupdate.
|
||||
- Use event.stopPropagation() for action controls that must not select the row.
|
||||
- 'Non-default field mapping is declarative: `idfield="key" labelfield="title" summaryfield="desc"`.'
|
||||
|
||||
examples: |
|
||||
<script>
|
||||
const users = [
|
||||
{ id: 'ada', label: 'Ada', summary: 'Admin' },
|
||||
{ id: 'lin', label: 'Lin', summary: 'Editor' }
|
||||
]
|
||||
const groups = [{ id: 'ops', label: 'Operations' }]
|
||||
const groupedUsers = [{ id: 'ada', label: 'Ada', group: 'ops' }]
|
||||
const treeUsers = [
|
||||
{ id: 'root', label: 'Root', parent: '' },
|
||||
{ id: 'child', label: 'Child', parent: 'root' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<List $.state.list="users" auto-select></List>
|
||||
|
||||
<List $.state.list="users" auto-select>
|
||||
<button slot="item-actions" $onclick="event.stopPropagation(); edit(item)">Edit</button>
|
||||
</List>
|
||||
|
||||
<List mode="group" $.state.groups="groups" $.state.list="groupedUsers"></List>
|
||||
|
||||
<List mode="tree" collapsible $.state.list="treeUsers"></List>
|
||||
|
||||
<List fast $.state.list="users"></List>
|
||||
|
||||
data_shape:
|
||||
item: "{ id, label, summary }"
|
||||
group: "{ id, label, summary }"
|
||||
tree_item: "{ id, label, summary, parent }"
|
||||
|
||||
tests:
|
||||
- List.test.html
|
||||
27
components/Modal.js
Normal file
27
components/Modal.js
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Modal Component Module
|
||||
*/
|
||||
globalThis.Component.register('Modal', container => {
|
||||
container.modal = new bootstrap.Modal(container)
|
||||
container.addEventListener('bind', e => {
|
||||
e.detail ? container.modal.show() : container.modal.hide()
|
||||
})
|
||||
container.addEventListener('hide.bs.modal', () => {
|
||||
document.activeElement?.blur()
|
||||
container.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: false }))
|
||||
})
|
||||
globalThis.Util.copyFunction(container, container.modal, 'show', 'hide')
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="modal fade" data-bs-backdrop="static">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div $class="modal-content border-\${this.state?.type || 'primary'} border-2 shadow-lg">
|
||||
<div 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>
|
||||
<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>
|
||||
</div>
|
||||
<div slot-id="body" class="modal-body p-3"></div>
|
||||
<div slot-id="footer" class="modal-footer py-2 px-3 bg-light"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
1
components/Modal.min.js
vendored
Normal file
1
components/Modal.min.js
vendored
Normal file
@ -0,0 +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}))}),globalThis.Util.copyFunction(t,t.modal,"show","hide")},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'));
|
||||
28
components/Modal.yaml
Normal file
28
components/Modal.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
name: Modal
|
||||
|
||||
binding:
|
||||
behavior: Bind a boolean with $bind to show or hide the modal.
|
||||
|
||||
state:
|
||||
title: Default header title.
|
||||
type: Bootstrap contextual type. Default is primary.
|
||||
|
||||
methods:
|
||||
show: Show the Bootstrap modal.
|
||||
hide: Hide the Bootstrap modal.
|
||||
|
||||
events:
|
||||
change:
|
||||
detail: false when the modal is hidden.
|
||||
|
||||
slots:
|
||||
header: Replace the modal header.
|
||||
body: Replace the modal body.
|
||||
footer: Replace the modal footer.
|
||||
|
||||
example: |
|
||||
<Modal id="editModal" title="Edit user">
|
||||
<div slot="body"><input id="nameInput"></div>
|
||||
<div slot="footer"><button $onclick="editModal.hide()">Close</button></div>
|
||||
</Modal>
|
||||
<button $onclick="editModal.show()">Edit</button>
|
||||
114
components/Nav.js
Normal file
114
components/Nav.js
Normal file
@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Nav Component Module
|
||||
*/
|
||||
globalThis.Component.register('Nav', container => {
|
||||
container.vertical = container.hasAttribute('vertical')
|
||||
container.state.openName = container.state.openName || null
|
||||
container.state.value = container.state.value || null
|
||||
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.value = e.detail
|
||||
})
|
||||
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) {
|
||||
container.state.value = item.name
|
||||
container.dispatchEvent(new CustomEvent('change', { detail: item.name, bubbles: false }))
|
||||
}
|
||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
|
||||
}
|
||||
container.clickSubitem = item => {
|
||||
container.state.value = item.name
|
||||
container.dispatchEvent(new CustomEvent('change', { detail: item.name, bubbles: false }))
|
||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item }, bubbles: false }))
|
||||
}
|
||||
container.toggleGroup = item => {
|
||||
container.state.openName = container.state.openName === item.name ? null : item.name
|
||||
container.dispatchEvent(new CustomEvent('nav', { detail: { item, open: container.state.openName === item.name }, bubbles: false }))
|
||||
}
|
||||
}, 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="d-flex align-items-center gap-2">
|
||||
<template $if="this.state?.brand?.image">
|
||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'mx-1'}" style="height:30px;width:auto;max-width:300px">
|
||||
</template>
|
||||
<template $if="this.state?.brand?.icon">
|
||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'mx-1'}"></i>
|
||||
</template>
|
||||
<template $if="this.state?.brand?.label">
|
||||
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'mx-1'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
|
||||
<template $each="this.state?.list || []">
|
||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}" style="transform: translateY(3px);">
|
||||
<template $if="item.type==='label'">
|
||||
<div $class="\${this.vertical ? 'small text-uppercase text-body-secondary fw-semibold px-2 py-1 mt-2' : 'navbar-text small text-uppercase text-body-secondary fw-semibold px-2'} \${item.class || ''}" $text="item.label"></div>
|
||||
</template>
|
||||
<template $if="item.type==='button'">
|
||||
<button $class="nav-link \${this.state.value===item.name?'active':''} \${this.vertical ? 'w-100 text-start py-1 px-2' : ''} \${item.class || ''}" $onclick="this.click(item)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? 'text-truncate' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="item.type==='dropdown'">
|
||||
<template $if="this.vertical">
|
||||
<div class="w-100">
|
||||
<button type="button" $class="nav-link w-100 text-start py-1 px-2 d-flex align-items-center justify-content-between \${item.class || ''}" $onclick="this.toggleGroup(item)">
|
||||
<span class="d-inline-flex align-items-center">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span class="text-truncate" $text="item.label"></span>
|
||||
</span>
|
||||
<i $class="bi \${this.state.openName===item.name?'bi-chevron-down':'bi-chevron-right'} small ms-2"></i>
|
||||
</button>
|
||||
<template $if="this.state.openName===item.name">
|
||||
<div class="d-flex flex-column w-100 ps-2 mt-1">
|
||||
<template $each="item.list" as="subitem">
|
||||
<template $if="subitem.type==='label'">
|
||||
<div $class="small text-uppercase text-body-secondary fw-semibold px-2 py-1 \${subitem.class || ''}" $text="subitem.label"></div>
|
||||
</template>
|
||||
<template $if="subitem.type==='button'">
|
||||
<button $class="nav-link w-100 text-start py-1 px-2 \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="subitem.type==='switch'">
|
||||
<div $class="d-flex align-items-center px-2 py-2 \${subitem.class || ''}">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template $if="!this.vertical">
|
||||
<div class="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>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end p-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px;'">
|
||||
<template $each="item.list" as="subitem">
|
||||
<template $if="subitem.type==='label'">
|
||||
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>
|
||||
</template>
|
||||
<template $if="subitem.type==='button'">
|
||||
<button $class="dropdown-item \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="subitem.type==='switch'">
|
||||
<div $class="d-flex align-items-center px-3 py-2 \${subitem.class || ''}">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
1
components/Nav.min.js
vendored
Normal file
1
components/Nav.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
31
components/Nav.yaml
Normal file
31
components/Nav.yaml
Normal file
@ -0,0 +1,31 @@
|
||||
name: Nav
|
||||
|
||||
attributes:
|
||||
vertical:
|
||||
type: boolean
|
||||
|
||||
state:
|
||||
brand: "{ image?, icon?, label? }"
|
||||
list: Array of navigation item objects; assign `nav.state.list = items`.
|
||||
value: Selected item `name`.
|
||||
openName: Open dropdown name in vertical mode.
|
||||
|
||||
item_types:
|
||||
label: Non-clickable text.
|
||||
button: Selectable navigation item.
|
||||
dropdown: Group of label, button, or switch items.
|
||||
fill: Horizontal spacer item.
|
||||
|
||||
events:
|
||||
change:
|
||||
detail: Selected item name.
|
||||
nav:
|
||||
detail: '{ item } or { item, open } for vertical dropdown changes.'
|
||||
|
||||
item_shape: "{ type, name, label, icon?, items? }"
|
||||
|
||||
examples:
|
||||
- EXAMPLE/Nav/Basic.yaml
|
||||
- EXAMPLE/Nav/Dropdown.yaml
|
||||
|
||||
example: EXAMPLE/Nav/Basic.yaml
|
||||
11
components/Nav/Basic.yaml
Normal file
11
components/Nav/Basic.yaml
Normal file
@ -0,0 +1,11 @@
|
||||
purpose: Basic navigation.
|
||||
|
||||
code: |
|
||||
<Nav id="mainNav"></Nav>
|
||||
|
||||
data: |
|
||||
const nav = document.querySelector('#mainNav')
|
||||
nav.state.list = [
|
||||
{ type: 'button', name: 'home', label: 'Home', icon: 'house' },
|
||||
{ type: 'button', name: 'settings', label: 'Settings', icon: 'gear' }
|
||||
]
|
||||
15
components/Nav/Dropdown.yaml
Normal file
15
components/Nav/Dropdown.yaml
Normal file
@ -0,0 +1,15 @@
|
||||
purpose: Navigation with a dropdown and a listener for selection.
|
||||
|
||||
code: |
|
||||
<Nav id="mainNav" vertical></Nav>
|
||||
<script>
|
||||
const nav = document.querySelector('#mainNav')
|
||||
nav.state.list = [
|
||||
{ type: 'button', name: 'home', label: 'Home', icon: 'house' },
|
||||
{ type: 'dropdown', name: 'admin', label: 'Admin', items: [
|
||||
{ type: 'button', name: 'users', label: 'Users' },
|
||||
{ type: 'button', name: 'roles', label: 'Roles' }
|
||||
] }
|
||||
]
|
||||
nav.addEventListener('change', event => openPage(event.detail))
|
||||
</script>
|
||||
36
components/Resizer.js
Normal file
36
components/Resizer.js
Normal file
@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Resizer Component
|
||||
*/
|
||||
globalThis.Component.register('Resizer', container => {
|
||||
container.isVertical = container.hasAttribute('vertical')
|
||||
const min = parseInt(container.getAttribute('min')) || 10
|
||||
const max = parseInt(container.getAttribute('max')) || 1000
|
||||
const target = () => container.target || container.previousElementSibling
|
||||
container.addEventListener('bind', e => {
|
||||
if (e.detail !== undefined && e.detail !== null) {
|
||||
target().style[container.isVertical ? 'height' : 'width'] = e.detail + 'px'
|
||||
}
|
||||
})
|
||||
const getSize = (startSize, w, h) => {
|
||||
const newSize = startSize + (container.isVertical ? h : w)
|
||||
return newSize < min ? min : newSize > max ? max : newSize
|
||||
}
|
||||
container.addEventListener('mousedown', event => {
|
||||
const element = target()
|
||||
const startSize = container.isVertical ? element.offsetHeight : element.offsetWidth
|
||||
globalThis.MouseMover.start(event, {
|
||||
onmousemove: ({ w, h }) => {
|
||||
const newSize = getSize(startSize, w, h)
|
||||
element.style[container.isVertical ? 'height' : 'width'] = newSize + 'px'
|
||||
container.dispatchEvent(new CustomEvent('resizing', { detail: { oldSize: startSize, newSize }, bubbles: false }))
|
||||
},
|
||||
onmouseup: ({ w, h }) => {
|
||||
const newSize = getSize(startSize, w, h)
|
||||
container.dispatchEvent(new CustomEvent('resize', { detail: { oldSize: startSize, newSize }, bubbles: false }))
|
||||
container.dispatchEvent(new CustomEvent('change', { detail: newSize, bubbles: false }))
|
||||
},
|
||||
})
|
||||
})
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div $class="border-\${this.isVertical?'top':'start'} flex-shrink-0" $style="\${this.isVertical?'height':'width'}:3px;\${!this.isVertical?'height':'width'}:100%;cursor:\${this.isVertical?'row-resize':'col-resize'}"></div>
|
||||
`))
|
||||
1
components/Resizer.min.js
vendored
Normal file
1
components/Resizer.min.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
globalThis.Component.register("Resizer",e=>{e.isVertical=e.hasAttribute("vertical");const t=parseInt(e.getAttribute("min"))||10,i=parseInt(e.getAttribute("max"))||1e3,s=()=>e.target||e.previousElementSibling;e.addEventListener("bind",t=>{void 0!==t.detail&&null!==t.detail&&(s().style[e.isVertical?"height":"width"]=t.detail+"px")});const n=(s,n,l)=>{const r=s+(e.isVertical?l:n);return r<t?t:r>i?i:r};e.addEventListener("mousedown",t=>{const i=s(),l=e.isVertical?i.offsetHeight:i.offsetWidth;globalThis.MouseMover.start(t,{onmousemove:({w:t,h:s})=>{const r=n(l,t,s);i.style[e.isVertical?"height":"width"]=r+"px",e.dispatchEvent(new CustomEvent("resizing",{detail:{oldSize:l,newSize:r},bubbles:!1}))},onmouseup:({w:t,h:i})=>{const s=n(l,t,i);e.dispatchEvent(new CustomEvent("resize",{detail:{oldSize:l,newSize:s},bubbles:!1})),e.dispatchEvent(new CustomEvent("change",{detail:s,bubbles:!1}))}})})},globalThis.Util.makeDom("\n<div $class=\"border-${this.isVertical?'top':'start'} flex-shrink-0\" $style=\"${this.isVertical?'height':'width'}:3px;${!this.isVertical?'height':'width'}:100%;cursor:${this.isVertical?'row-resize':'col-resize'}\"></div>\n"));
|
||||
38
components/Resizer.test.html
Normal file
38
components/Resizer.test.html
Normal file
@ -0,0 +1,38 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>components.Resizer tests</title>
|
||||
<script src="../ui.js" components="Resizer"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="d-flex align-items-stretch border mb-3" style="height:80px"><div id="widthTarget" class="bg-primary" style="width:100px"></div><Resizer id="widthResizer" min="10" max="200"></Resizer></div>
|
||||
<div class="d-flex flex-column border mb-3" style="width:120px"><div id="heightTarget" class="bg-success" style="height:40px"></div><Resizer id="heightResizer" vertical min="20" max="80"></Resizer></div>
|
||||
<div class="d-flex align-items-stretch border" style="height:80px"><div id="customTarget" class="bg-warning" style="width:50px"></div><Resizer id="customResizer" $.target="customTarget"></Resizer></div>
|
||||
<button id="runAll" type="button">测试全部</button>
|
||||
<pre id="result"></pre>
|
||||
<script>
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 20))
|
||||
const cases = ['target', 'min', 'max', 'vertical', 'binding', 'resizing-event', 'resize-event', 'change-event']
|
||||
const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
|
||||
const drag = (resizer, x, y) => { resizer.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 0, clientY: 0 })); document.dispatchEvent(new MouseEvent('mousemove', { clientX: x, clientY: y })); document.dispatchEvent(new MouseEvent('mouseup', { clientX: x, clientY: y })) }
|
||||
window.runTest = async () => {
|
||||
const testResult = { name: 'components.Resizer', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }
|
||||
const widthTarget = document.querySelector('#widthTarget'); const widthResizer = document.querySelector('#widthResizer'); const heightTarget = document.querySelector('#heightTarget'); const heightResizer = document.querySelector('#heightResizer'); const customTarget = document.querySelector('#customTarget'); const customResizer = document.querySelector('#customResizer')
|
||||
let resizing; let resize; let change
|
||||
widthResizer.addEventListener('resizing', event => { resizing = event.detail }, { once: true }); widthResizer.addEventListener('resize', event => { resize = event.detail }, { once: true }); widthResizer.addEventListener('change', event => { change = event.detail }, { once: true })
|
||||
drag(widthResizer, 30, 0); await tick()
|
||||
assert(testResult, 'target', widthTarget.style.width === '130px', 'default target is the previous sibling')
|
||||
assert(testResult, 'resizing-event', resizing?.oldSize === 100 && resizing?.newSize === 130, 'resizing detail has oldSize and newSize')
|
||||
assert(testResult, 'resize-event', resize?.oldSize === 100 && resize?.newSize === 130, 'resize detail has oldSize and newSize')
|
||||
assert(testResult, 'change-event', change === 130, 'change detail is final size')
|
||||
drag(widthResizer, -300, 0); await tick(); assert(testResult, 'min', widthTarget.style.width === '10px', 'min clamps width')
|
||||
drag(widthResizer, 300, 0); await tick(); assert(testResult, 'max', widthTarget.style.width === '200px', 'max clamps width')
|
||||
drag(heightResizer, 0, 20); await tick(); assert(testResult, 'vertical', heightTarget.style.height === '60px', 'vertical resizer changes height')
|
||||
customResizer.dispatchEvent(new CustomEvent('bind', { detail: 75 })); await tick(); assert(testResult, 'binding', customTarget.style.width === '75px', '$.target and bind update a custom target')
|
||||
testResult.coverage.tested = [...cases]; testResult.coverage.percent = 100; testResult.status = testResult.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult
|
||||
}
|
||||
document.querySelector('#runAll').onclick = window.runTest
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
33
components/Resizer.yaml
Normal file
33
components/Resizer.yaml
Normal file
@ -0,0 +1,33 @@
|
||||
name: Resizer
|
||||
|
||||
attributes:
|
||||
vertical: Resize height instead of width.
|
||||
min: Minimum size in pixels. Default is 10.
|
||||
max: Maximum size in pixels. Default is 1000.
|
||||
|
||||
properties:
|
||||
target: Target element. Defaults to the previous sibling.
|
||||
|
||||
binding:
|
||||
behavior: '$bind or a bind event sets the target size in pixels.'
|
||||
|
||||
events:
|
||||
resizing:
|
||||
detail: "{ oldSize, newSize }"
|
||||
resize:
|
||||
detail: "{ oldSize, newSize }"
|
||||
change:
|
||||
detail: Final size in pixels.
|
||||
|
||||
rules:
|
||||
- MouseMover is an internal dependency and is loaded automatically.
|
||||
|
||||
example: |
|
||||
<div id="sidebar" style="width:240px"></div>
|
||||
<Resizer min="160" max="480" $bind="State.sidebarWidth" $onchange="State.sidebarWidth = event.detail"></Resizer>
|
||||
|
||||
<div id="bottomPanel" style="height:240px"></div>
|
||||
<Resizer vertical min="120" max="480" $.target="bottomPanel"></Resizer>
|
||||
|
||||
tests:
|
||||
- Resizer.test.html
|
||||
49
components/Toast.js
Normal file
49
components/Toast.js
Normal file
@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Toast Component
|
||||
*/
|
||||
globalThis.Component.register('Toast', container => {
|
||||
container.toast = new bootstrap.Toast(container, { autohide: container.state.delay > 0 })
|
||||
globalThis.Util.copyFunction(container, container.toast, 'show', 'hide')
|
||||
container.addEventListener('show.bs.toast', () => {
|
||||
if (container.state.delay > 0) {
|
||||
let timer
|
||||
const startTimer = () => {
|
||||
container.state.left = container.state.delay / 1000
|
||||
timer = setInterval(() => {
|
||||
if (!container.isConnected || --container.state.left <= 0) clearInterval(timer)
|
||||
}, 1000)
|
||||
}
|
||||
startTimer()
|
||||
container.addEventListener('mouseenter', () => { clearInterval(timer); container.state.left = undefined })
|
||||
container.addEventListener('mouseleave', startTimer)
|
||||
}
|
||||
})
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="toast align-items-center border-0 m-1">
|
||||
<div $class="toast-body rounded p-3 text-bg-\${this.state?.type}">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="flex-grow-1">
|
||||
<span style="white-space:pre-wrap" class="fs-6" $text="this.state?.message"></span>
|
||||
<template $if="this.state?.left !== undefined"><span class="small text-dim ms-2" $text="\${this.state?.left}s"></span></template>
|
||||
</div>
|
||||
<button type="button" class="btn btn-link ms-3 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="toast"></button>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-3"><template $each="this.state?.buttons || ['{#Close#}']"><button type="button" $class="btn btn-sm btn-\${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button></template></div>
|
||||
</div>
|
||||
</div>
|
||||
`), globalThis.Util.makeDom(/*html*/`<div toast-container="default" class="position-fixed bottom-0 end-0 overflow-auto" style="z-index:3000;max-height:80%"></div>`))
|
||||
|
||||
globalThis.Toast = {
|
||||
show(message, options = {}) {
|
||||
const delay = options.delay ?? 5000
|
||||
const toast = document.createElement('Toast')
|
||||
toast.state = { delay, left: delay ? delay / 1000 : undefined, type: options.type || 'primary', message, buttons: options.buttons ?? ['{#Close#}'] }
|
||||
globalThis.$(`[toast-container="${options.container || 'default'}"]`).appendChild(toast)
|
||||
return Promise.resolve().then(() => { toast.show(); return toast })
|
||||
},
|
||||
confirm(message, options = {}) {
|
||||
return new Promise(resolve => this.show(message, { buttons: ['{#Confirm#}'], ...options }).then(toast => {
|
||||
toast.addEventListener('hidden.bs.toast', () => resolve(toast.result === 1), { once: true })
|
||||
}).catch(() => resolve(false)))
|
||||
}
|
||||
}
|
||||
1
components/Toast.min.js
vendored
Normal file
1
components/Toast.min.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
globalThis.Component.register("Toast",t=>{t.toast=new bootstrap.Toast(t,{autohide:t.state.delay>0}),globalThis.Util.copyFunction(t,t.toast,"show","hide"),t.addEventListener("show.bs.toast",()=>{if(t.state.delay>0){let e;const s=()=>{t.state.left=t.state.delay/1e3,e=setInterval(()=>{(!t.isConnected||--t.state.left<=0)&&clearInterval(e)},1e3)};s(),t.addEventListener("mouseenter",()=>{clearInterval(e),t.state.left=void 0}),t.addEventListener("mouseleave",s)}})},globalThis.Util.makeDom('\n<div class="toast align-items-center border-0 m-1">\n <div $class="toast-body rounded p-3 text-bg-${this.state?.type}">\n <div class="d-flex align-items-center">\n <div class="flex-grow-1">\n <span style="white-space:pre-wrap" class="fs-6" $text="this.state?.message"></span>\n <template $if="this.state?.left !== undefined"><span class="small text-dim ms-2" $text="${this.state?.left}s"></span></template>\n </div>\n <button type="button" class="btn btn-link ms-3 bi bi-x-lg link-reset" style="color:inherit" data-bs-dismiss="toast"></button>\n </div>\n <div class="d-flex justify-content-end gap-3"><template $each="this.state?.buttons || [\'{#Close#}\']"><button type="button" $class="btn btn-sm btn-${this.state?.type} mt-2" data-bs-dismiss="toast" $onclick="this.result=index+1" $text="item"></button></template></div>\n </div>\n</div>\n'),globalThis.Util.makeDom('<div toast-container="default" class="position-fixed bottom-0 end-0 overflow-auto" style="z-index:3000;max-height:80%"></div>')),globalThis.Toast={show(t,e={}){const s=e.delay??5e3,a=document.createElement("Toast");return a.state={delay:s,left:s?s/1e3:void 0,type:e.type||"primary",message:t,buttons:e.buttons??["{#Close#}"]},globalThis.$(`[toast-container="${e.container||"default"}"]`).appendChild(a),Promise.resolve().then(()=>(a.show(),a))},confirm(t,e={}){return new Promise(s=>this.show(t,{buttons:["{#Confirm#}"],...e}).then(t=>{t.addEventListener("hidden.bs.toast",()=>s(1===t.result),{once:!0})}).catch(()=>s(!1)))}};
|
||||
31
components/Toast.test.html
Normal file
31
components/Toast.test.html
Normal file
@ -0,0 +1,31 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>components.Toast tests</title>
|
||||
<script src="../ui.js" components="Toast"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div toast-container="custom"></div>
|
||||
<button id="runAll" type="button">测试全部</button>
|
||||
<pre id="result"></pre>
|
||||
<script>
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 40))
|
||||
const cases = ['show', 'type', 'delay', 'buttons', 'container', 'confirm-true', 'confirm-false']
|
||||
const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
|
||||
window.runTest = async () => {
|
||||
const testResult = { name: 'components.Toast', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }
|
||||
const inheritedToast = await Toast.show('Default'); await tick(); const defaultToast = await Toast.show('Saved', { type: 'success', delay: 0, buttons: ['Retry'] }); await tick()
|
||||
assert(testResult, 'show', defaultToast.isConnected && defaultToast.textContent.includes('Saved'), 'show returns and displays a Toast element')
|
||||
assert(testResult, 'type', defaultToast.querySelector('.text-bg-success'), 'type applies the Bootstrap contextual class')
|
||||
assert(testResult, 'delay', inheritedToast.state.delay === 5000 && defaultToast.state.delay === 0 && defaultToast.state.left === undefined, 'default delay is 5000 and delay 0 keeps the toast open')
|
||||
assert(testResult, 'buttons', inheritedToast.querySelector('button.btn-sm')?.textContent.includes('Close') && defaultToast.querySelector('button.btn-sm')?.textContent === 'Retry', 'buttons default to Close and render supplied labels')
|
||||
const customToast = await Toast.show('Custom', { container: 'custom', delay: 0 }); await tick(); assert(testResult, 'container', customToast.parentElement.getAttribute('toast-container') === 'custom', 'container selects a named toast container')
|
||||
const yes = Toast.confirm('Delete?', { delay: 0 }); await tick(); [...document.querySelectorAll('Toast')].at(-1).querySelector('button.btn-sm').click(); assert(testResult, 'confirm-true', await yes === true, 'confirm resolves true after Confirm')
|
||||
const no = Toast.confirm('Cancel?', { delay: 0 }); await tick(); [...document.querySelectorAll('Toast')].at(-1).querySelector('[data-bs-dismiss="toast"]').click(); assert(testResult, 'confirm-false', await no === false, 'confirm resolves false after close')
|
||||
testResult.coverage.tested = [...cases]; testResult.coverage.percent = 100; testResult.status = testResult.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult
|
||||
}
|
||||
document.querySelector('#runAll').onclick = window.runTest
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
25
components/Toast.yaml
Normal file
25
components/Toast.yaml
Normal file
@ -0,0 +1,25 @@
|
||||
name: Toast
|
||||
|
||||
methods:
|
||||
show: 'Toast.show(message, options) -> Promise<Toast element>'
|
||||
confirm: 'Toast.confirm(message, options) -> Promise<boolean>'
|
||||
|
||||
options:
|
||||
type: 'Bootstrap contextual type: primary|secondary|success|info|warning|danger'
|
||||
delay: 'milliseconds; default 5000, 0 keeps the toast open'
|
||||
buttons: 'button labels; default is Close'
|
||||
container: 'toast-container name; default is default'
|
||||
|
||||
behavior:
|
||||
buttons: 'Button click stores a 1-based result on the returned Toast element before it closes.'
|
||||
confirm: 'Confirm resolves true only when the Confirm button is clicked; close resolves false.'
|
||||
|
||||
examples: |
|
||||
Toast.show('Saved', { type: 'success' })
|
||||
const confirmed = await Toast.confirm('Delete this record?')
|
||||
|
||||
<div toast-container="custom"></div>
|
||||
Toast.show('Retry upload?', { type: 'warning', delay: 0, buttons: ['Retry'], container: 'custom' })
|
||||
|
||||
tests:
|
||||
- Toast.test.html
|
||||
26
components/form/ColorPicker.js
Normal file
26
components/form/ColorPicker.js
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* ColorPicker Component
|
||||
*/
|
||||
globalThis.Component.register('ColorPicker', container => {
|
||||
container.state = globalThis.NewState({ value: '#000000' })
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.value = e.detail || '#000000'
|
||||
})
|
||||
Object.defineProperty(container, 'value', {
|
||||
get: () => container.state.value,
|
||||
set: v => { container.state.value = v || '#000000'; }
|
||||
})
|
||||
container.updateValue = (val) => {
|
||||
container.state.value = val
|
||||
container.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: val }))
|
||||
}
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="d-flex align-items-center gap-2 w-100 h-100">
|
||||
<input type="color" class="form-control form-control-color flex-shrink-0" style="width: 3rem; padding: 0.25rem" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">
|
||||
<input type="text" class="form-control" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">
|
||||
</div>
|
||||
`))
|
||||
|
||||
if (globalThis.AutoForm) {
|
||||
globalThis.AutoForm.register('ColorPicker')
|
||||
}
|
||||
1
components/form/ColorPicker.min.js
vendored
Normal file
1
components/form/ColorPicker.min.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
globalThis.Component.register("ColorPicker",e=>{e.state=globalThis.NewState({value:"#000000"}),e.addEventListener("bind",t=>{e.state.value=t.detail||"#000000"}),Object.defineProperty(e,"value",{get:()=>e.state.value,set:t=>{e.state.value=t||"#000000"}}),e.updateValue=t=>{e.state.value=t,e.dispatchEvent(new CustomEvent("change",{bubbles:!0,detail:t}))}},globalThis.Util.makeDom('\n<div class="d-flex align-items-center gap-2 w-100 h-100">\n <input type="color" class="form-control form-control-color flex-shrink-0" style="width: 3rem; padding: 0.25rem" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">\n <input type="text" class="form-control" $bind="this.state.value" $onchange="this.updateValue(thisNode.value)">\n</div>\n')),globalThis.AutoForm&&globalThis.AutoForm.register("ColorPicker");
|
||||
46
components/form/ColorPicker.test.html
Normal file
46
components/form/ColorPicker.test.html
Normal file
@ -0,0 +1,46 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>components.form.ColorPicker tests</title>
|
||||
<script>
|
||||
const theme = { accent: '#0d6efd' }
|
||||
const themeSchema = [{ name: 'accent', label: 'Accent', type: 'ColorPicker' }]
|
||||
</script>
|
||||
<script src="../../ui.js" components="AutoForm,ColorPicker"></script>
|
||||
</head>
|
||||
<body class="vh-100 overflow-hidden bg-body-tertiary">
|
||||
<main class="container py-3 h-100 d-flex flex-column gap-3">
|
||||
<div class="d-flex align-items-center gap-2 flex-shrink-0"><h1 class="h5 mb-0">ColorPicker</h1><button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button></div>
|
||||
<div class="row g-3 flex-fill overflow-auto">
|
||||
<section class="col-md-6"><div class="card h-100"><div class="card-header">Direct binding</div><div class="card-body"><ColorPicker id="accent" $bind="theme.accent"></ColorPicker></div></div></section>
|
||||
<section class="col-md-6"><div class="card h-100"><div class="card-header">AutoForm</div><div class="card-body"><AutoForm id="themeForm" nobutton $.state.data="theme" $.state.schema="themeSchema"></AutoForm></div></div></section>
|
||||
</div>
|
||||
<pre id="result" class="card card-body mb-0 flex-shrink-0 overflow-auto" style="height:25%">请选择测试</pre>
|
||||
</main>
|
||||
<script>
|
||||
const features = ['properties.value', 'state.value', 'events.change', 'autoform']
|
||||
const pause = () => new Promise(resolve => setTimeout(resolve, 80))
|
||||
const assert = (result, feature, condition, message) => { result.assertions++; if (condition) result.passed++; else { result.failed++; result.failures.push({ feature, message }) } }
|
||||
window.runTest = async () => {
|
||||
const result = { name: 'components.form.ColorPicker', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: features, percent: 0 } }
|
||||
await pause()
|
||||
const accent = document.querySelector('#accent')
|
||||
const textInput = accent.querySelector('input[type="text"]')
|
||||
const colorInput = accent.querySelector('input[type="color"]')
|
||||
assert(result, 'properties.value', accent.value === '#0d6efd', 'value exposes the bound color')
|
||||
assert(result, 'state.value', accent.state.value === '#0d6efd' && colorInput.value === '#0d6efd' && textInput.value === '#0d6efd', 'state.value keeps both controls in sync')
|
||||
let changed
|
||||
accent.addEventListener('change', event => { changed = event.detail }, { once: true })
|
||||
textInput.value = '#198754'; textInput.dispatchEvent(new Event('change', { bubbles: true })); await pause()
|
||||
assert(result, 'events.change', changed === '#198754' && theme.accent === '#198754' && colorInput.value === '#198754', 'change exposes and writes the selected color')
|
||||
const formPicker = document.querySelector('#themeForm ColorPicker')
|
||||
const formText = formPicker?.querySelector('input[type="text"]')
|
||||
assert(result, 'autoform', !!formPicker && formText?.value === '#0d6efd', 'AutoForm type ColorPicker renders and receives initial form data')
|
||||
result.coverage.tested = [...features]; result.coverage.percent = 100; result.status = result.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(result, null, 2); window.testResult = result; return result
|
||||
}
|
||||
document.querySelector('#runAll').onclick = window.runTest
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
28
components/form/ColorPicker.yaml
Normal file
28
components/form/ColorPicker.yaml
Normal file
@ -0,0 +1,28 @@
|
||||
name: ColorPicker
|
||||
purpose: Native color input paired with an editable CSS color value.
|
||||
|
||||
properties:
|
||||
value: string; CSS color, normally #rrggbb
|
||||
|
||||
state:
|
||||
value: string; CSS color, normally #rrggbb
|
||||
|
||||
events:
|
||||
change: detail is the selected color string
|
||||
|
||||
examples:
|
||||
standalone_and_form: |
|
||||
<script>
|
||||
const theme = { accent: '#0d6efd' }
|
||||
const themeSchema = [{ name: 'accent', label: 'Accent', type: 'ColorPicker' }]
|
||||
</script>
|
||||
|
||||
<ColorPicker $bind="theme.accent"></ColorPicker>
|
||||
<AutoForm $.state.data="theme" $.state.schema="themeSchema"></AutoForm>
|
||||
|
||||
rules:
|
||||
- Define page data before ColorPicker is parsed.
|
||||
- Use $bind for a standalone reactive value; use AutoForm schema type ColorPicker for generated forms.
|
||||
|
||||
related:
|
||||
- ../AutoForm.yaml
|
||||
60
components/form/DatePicker.js
Normal file
60
components/form/DatePicker.js
Normal file
@ -0,0 +1,60 @@
|
||||
/**
|
||||
* DatePicker Component
|
||||
*/
|
||||
globalThis.Component.register('DatePicker', container => {
|
||||
container.state = globalThis.NewState({ start: '', end: '' })
|
||||
|
||||
const rangeEnd = () => {
|
||||
const form = container.closest('AutoForm')
|
||||
const name = container.getAttribute('name')
|
||||
const item = form?.state?.schema?.find(i => i.name === name)
|
||||
return item?.setting?.rangeEnd || container.rangeEnd || container.getAttribute('rangeend') || ''
|
||||
}
|
||||
|
||||
container.addEventListener('bind', e => {
|
||||
container.state.start = e.detail || ''
|
||||
const form = container.closest('AutoForm')
|
||||
const endName = rangeEnd()
|
||||
if (form && endName) {
|
||||
container.state.end = form.data[endName] || ''
|
||||
}
|
||||
})
|
||||
|
||||
Object.defineProperty(container, 'isRange', {
|
||||
get: () => {
|
||||
return container.hasAttribute('range') || !!rangeEnd()
|
||||
}
|
||||
})
|
||||
|
||||
Object.defineProperty(container, 'value', {
|
||||
get: () => container.state.start,
|
||||
set: v => { container.state.start = v || ''; }
|
||||
})
|
||||
|
||||
container.updateStart = (val) => {
|
||||
container.state.start = val
|
||||
container.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: val }))
|
||||
}
|
||||
|
||||
container.updateEnd = (val) => {
|
||||
container.state.end = val
|
||||
const form = container.closest('AutoForm')
|
||||
const endName = rangeEnd()
|
||||
if (form && endName) {
|
||||
form.data[endName] = val
|
||||
}
|
||||
container.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: val }))
|
||||
}
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="d-flex align-items-center gap-1 w-100">
|
||||
<input type="date" class="form-control h-100" $bind="this.state.start" $onchange="this.updateStart(thisNode.value)">
|
||||
<template $if="this.isRange">
|
||||
<span class="text-muted mx-1">-</span>
|
||||
<input type="date" class="form-control h-100" $bind="this.state.end" $onchange="this.updateEnd(thisNode.value)">
|
||||
</template>
|
||||
</div>
|
||||
`))
|
||||
|
||||
if (globalThis.AutoForm) {
|
||||
globalThis.AutoForm.register('DatePicker')
|
||||
}
|
||||
1
components/form/DatePicker.min.js
vendored
Normal file
1
components/form/DatePicker.min.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
globalThis.Component.register("DatePicker",t=>{t.state=globalThis.NewState({start:"",end:""});const e=()=>{const e=t.closest("AutoForm"),a=t.getAttribute("name"),s=e?.state?.schema?.find(t=>t.name===a);return s?.setting?.rangeEnd||t.rangeEnd||t.getAttribute("rangeend")||""};t.addEventListener("bind",a=>{t.state.start=a.detail||"";const s=t.closest("AutoForm"),n=e();s&&n&&(t.state.end=s.data[n]||"")}),Object.defineProperty(t,"isRange",{get:()=>t.hasAttribute("range")||!!e()}),Object.defineProperty(t,"value",{get:()=>t.state.start,set:e=>{t.state.start=e||""}}),t.updateStart=e=>{t.state.start=e,t.dispatchEvent(new CustomEvent("change",{bubbles:!0,detail:e}))},t.updateEnd=a=>{t.state.end=a;const s=t.closest("AutoForm"),n=e();s&&n&&(s.data[n]=a),t.dispatchEvent(new CustomEvent("change",{bubbles:!0,detail:a}))}},globalThis.Util.makeDom('\n<div class="d-flex align-items-center gap-1 w-100">\n <input type="date" class="form-control h-100" $bind="this.state.start" $onchange="this.updateStart(thisNode.value)">\n <template $if="this.isRange">\n <span class="text-muted mx-1">-</span>\n <input type="date" class="form-control h-100" $bind="this.state.end" $onchange="this.updateEnd(thisNode.value)">\n </template>\n</div>\n')),globalThis.AutoForm&&globalThis.AutoForm.register("DatePicker");
|
||||
56
components/form/DatePicker.test.html
Normal file
56
components/form/DatePicker.test.html
Normal file
@ -0,0 +1,56 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>components.form.DatePicker tests</title>
|
||||
<script>
|
||||
const dates = { birthday: '1990-01-01', from: '2026-01-01', to: '2026-01-31' }
|
||||
const dateSchema = [{ name: 'from', label: 'Period', type: 'DatePicker', setting: { rangeEnd: 'to' } }]
|
||||
</script>
|
||||
<script src="../../ui.js" components="AutoForm,DatePicker"></script>
|
||||
</head>
|
||||
<body class="vh-100 overflow-hidden bg-body-tertiary">
|
||||
<main class="container py-3 h-100 d-flex flex-column gap-3">
|
||||
<div class="d-flex align-items-center gap-2 flex-shrink-0"><h1 class="h5 mb-0">DatePicker</h1><button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button></div>
|
||||
<div class="row g-3 flex-fill overflow-auto">
|
||||
<section class="col-md-6"><div class="card h-100"><div class="card-header">Direct binding</div><div class="card-body"><DatePicker id="birthday" $bind="dates.birthday"></DatePicker></div></div></section>
|
||||
<section class="col-md-6"><div class="card h-100"><div class="card-header">Standalone range</div><div class="card-body"><DatePicker id="standaloneRange" range></DatePicker></div></div></section>
|
||||
<section class="col-12"><div class="card"><div class="card-header">AutoForm range</div><div class="card-body"><AutoForm id="dateForm" nobutton $.state.data="dates" $.state.schema="dateSchema"></AutoForm></div></div></section>
|
||||
</div>
|
||||
<pre id="result" class="card card-body mb-0 flex-shrink-0 overflow-auto" style="height:25%">请选择测试</pre>
|
||||
</main>
|
||||
<script>
|
||||
const features = ['attributes.range', 'attributes.rangeend', 'properties.value', 'state.start', 'state.end', 'events.change', 'autoform']
|
||||
const pause = () => new Promise(resolve => setTimeout(resolve, 80))
|
||||
const assert = (result, feature, condition, message) => { result.assertions++; if (condition) result.passed++; else { result.failed++; result.failures.push({ feature, message }) } }
|
||||
window.runTest = async () => {
|
||||
const result = { name: 'components.form.DatePicker', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: features, percent: 0 } }
|
||||
await pause()
|
||||
const birthday = document.querySelector('#birthday')
|
||||
const standaloneRange = document.querySelector('#standaloneRange')
|
||||
const dateForm = document.querySelector('#dateForm')
|
||||
const birthdayInput = birthday.querySelector('input')
|
||||
assert(result, 'properties.value', birthday.value === '1990-01-01', 'value exposes the bound start date')
|
||||
assert(result, 'state.start', birthday.state.start === '1990-01-01' && birthdayInput.value === '1990-01-01', 'state.start renders in the input')
|
||||
let changed
|
||||
birthday.addEventListener('change', event => { changed = event.detail }, { once: true })
|
||||
birthdayInput.value = '1991-02-03'; birthdayInput.dispatchEvent(new Event('change', { bubbles: true })); await pause()
|
||||
assert(result, 'events.change', changed === '1991-02-03' && dates.birthday === '1991-02-03', 'change exposes and writes the new start date')
|
||||
const standaloneInputs = standaloneRange.querySelectorAll('input[type="date"]')
|
||||
assert(result, 'attributes.range', standaloneInputs.length === 2, 'range renders a second native date input')
|
||||
let endChanged
|
||||
standaloneRange.addEventListener('change', event => { endChanged = event.detail }, { once: true })
|
||||
standaloneInputs[1].value = '2026-02-28'; standaloneInputs[1].dispatchEvent(new Event('change', { bubbles: true })); await pause()
|
||||
assert(result, 'state.end', standaloneRange.state.end === '2026-02-28' && endChanged === '2026-02-28', 'state.end and change update for the end date')
|
||||
const formPicker = dateForm.querySelector('DatePicker')
|
||||
const formInputs = formPicker.querySelectorAll('input[type="date"]')
|
||||
assert(result, 'attributes.rangeend', formInputs.length === 2 && formInputs[1].value === '2026-01-31', 'schema setting.rangeEnd renders and initializes the paired field')
|
||||
formInputs[1].value = '2026-02-15'; formInputs[1].dispatchEvent(new Event('change', { bubbles: true })); await pause()
|
||||
assert(result, 'autoform', dateForm.data.to === '2026-02-15', 'range end writes the configured AutoForm field')
|
||||
result.coverage.tested = [...features]; result.coverage.percent = 100; result.status = result.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(result, null, 2); window.testResult = result; return result
|
||||
}
|
||||
document.querySelector('#runAll').onclick = window.runTest
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
38
components/form/DatePicker.yaml
Normal file
38
components/form/DatePicker.yaml
Normal file
@ -0,0 +1,38 @@
|
||||
name: DatePicker
|
||||
purpose: Native date input. In AutoForm, rangeEnd edits a second field as one date range.
|
||||
|
||||
attributes:
|
||||
range: boolean; show a standalone end-date input
|
||||
rangeend: string; second AutoForm data field; schema setting.rangeEnd takes precedence
|
||||
|
||||
properties:
|
||||
value: string; start date, YYYY-MM-DD
|
||||
|
||||
state:
|
||||
start: string; start date, YYYY-MM-DD
|
||||
end: string; end date, YYYY-MM-DD when range is enabled
|
||||
|
||||
events:
|
||||
change: detail is the changed date string
|
||||
|
||||
examples:
|
||||
single_and_range: |
|
||||
<script>
|
||||
const booking = { birthday: '1990-01-01', from: '2026-01-01', to: '2026-01-31' }
|
||||
const bookingSchema = [
|
||||
{ name: 'birthday', label: 'Birthday', type: 'DatePicker' },
|
||||
{ name: 'from', label: 'Period', type: 'DatePicker', setting: { rangeEnd: 'to' } }
|
||||
]
|
||||
</script>
|
||||
|
||||
<DatePicker $bind="booking.birthday"></DatePicker>
|
||||
<DatePicker range></DatePicker>
|
||||
<AutoForm $.state.data="booking" $.state.schema="bookingSchema"></AutoForm>
|
||||
|
||||
rules:
|
||||
- Define page data before DatePicker is parsed.
|
||||
- Use range for a standalone visual range; read its state.start and state.end.
|
||||
- In AutoForm, declare setting.rangeEnd on the start field; the end field does not need its own schema item.
|
||||
|
||||
related:
|
||||
- ../AutoForm.yaml
|
||||
74
components/form/IconPicker.js
Normal file
74
components/form/IconPicker.js
Normal file
File diff suppressed because one or more lines are too long
1
components/form/IconPicker.min.js
vendored
Normal file
1
components/form/IconPicker.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
78
components/form/IconPicker.test.html
Normal file
78
components/form/IconPicker.test.html
Normal file
@ -0,0 +1,78 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>components.form.IconPicker tests</title>
|
||||
<script>
|
||||
const selectedIcon = 'house'
|
||||
const menu = { icon: 'heart' }
|
||||
const menuSchema = [{ name: 'icon', label: 'Icon', type: 'IconPicker' }]
|
||||
</script>
|
||||
<script src="../../ui.js" components="IconPicker"></script>
|
||||
</head>
|
||||
<body class="vh-100 overflow-hidden bg-body-tertiary">
|
||||
<div class="container py-3 h-100 d-flex flex-column gap-3">
|
||||
<header class="d-flex align-items-center gap-2 flex-shrink-0">
|
||||
<h1 class="h4 mb-0">components.form.IconPicker</h1>
|
||||
<span id="status" class="badge text-bg-secondary">未测试</span>
|
||||
<button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button>
|
||||
</header>
|
||||
|
||||
<main class="flex-fill overflow-auto">
|
||||
<div class="card"><div class="card-body d-grid gap-3">
|
||||
<IconPicker id="iconPicker" style="height: 2.5rem" $.state.value="selectedIcon"></IconPicker>
|
||||
<AutoForm id="menuForm" $.state.data="menu" $.state.schema="menuSchema"></AutoForm>
|
||||
</div></div>
|
||||
</main>
|
||||
|
||||
<pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mb-0 flex-shrink-0 small"></pre>
|
||||
</div>
|
||||
<script>
|
||||
const cases = ['value', 'open', 'search', 'clear-search', 'change', 'outside-close', 'autoform']
|
||||
const consoleErrors = []
|
||||
const originalConsoleError = console.error
|
||||
console.error = (...args) => { consoleErrors.push(args.map(String).join(' ')); originalConsoleError(...args) }
|
||||
addEventListener('error', event => consoleErrors.push(String(event.message || event.error || event)))
|
||||
addEventListener('unhandledrejection', event => consoleErrors.push(String(event.reason || event)))
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 80))
|
||||
const assert = (result, feature, condition, message) => { result.assertions++; if (condition) result.passed++; else { result.failed++; result.failures.push({ feature, message }) } }
|
||||
const finish = result => {
|
||||
result.consoleErrors = [...consoleErrors]
|
||||
result.coverage.tested = [...cases]
|
||||
result.coverage.percent = 100
|
||||
result.status = result.failed || result.consoleErrors.length ? 'failed' : 'passed'
|
||||
const status = document.querySelector('#status')
|
||||
status.className = 'badge text-bg-' + (result.status === 'passed' ? 'success' : 'danger')
|
||||
status.textContent = result.status === 'passed' ? '通过' : '失败'
|
||||
document.querySelector('#result').textContent = JSON.stringify(result, null, 2)
|
||||
globalThis.testResult = result
|
||||
return result
|
||||
}
|
||||
globalThis.runTest = async () => {
|
||||
consoleErrors.length = 0
|
||||
const result = { name: 'components.form.IconPicker', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }
|
||||
await tick()
|
||||
const picker = document.querySelector('#iconPicker')
|
||||
assert(result, 'value', picker.value === 'house' && picker.textContent.includes('house'), 'direct value binding renders the selected icon')
|
||||
picker.querySelector('button').click(); await tick()
|
||||
assert(result, 'open', picker.state.open && picker.querySelector('.dropdown-menu').classList.contains('show'), 'trigger opens the icon menu')
|
||||
const search = picker.querySelector('input')
|
||||
search.value = 'alarm'; search.dispatchEvent(new Event('input')); await tick()
|
||||
assert(result, 'search', picker.querySelectorAll('button[title="alarm"]').length === 1, 'search narrows the icon choices')
|
||||
search.value = ''; search.dispatchEvent(new Event('input')); await tick()
|
||||
assert(result, 'clear-search', picker.querySelectorAll('button[title]').length === 606, 'clearing the search restores the complete icon list')
|
||||
search.value = 'alarm'; search.dispatchEvent(new Event('input')); await tick()
|
||||
let changed
|
||||
picker.addEventListener('change', event => { changed = event.detail }, { once: true })
|
||||
picker.querySelector('button[title="alarm"]').click(); await tick()
|
||||
assert(result, 'change', changed === 'alarm' && picker.value === 'alarm' && !picker.state.open, 'selecting an icon updates value, closes the menu, and emits its name')
|
||||
picker.querySelector('button').click(); await tick(); document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); await tick()
|
||||
assert(result, 'outside-close', !picker.state.open && picker.querySelector('.dropdown-menu').classList.contains('d-none'), 'outside click closes and hides the icon menu')
|
||||
assert(result, 'autoform', document.querySelector('#menuForm IconPicker')?.value === 'heart', 'AutoForm renders and binds the registered IconPicker field')
|
||||
return finish(result)
|
||||
}
|
||||
document.querySelector('#runAll').onclick = globalThis.runTest
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
21
components/form/IconPicker.yaml
Normal file
21
components/form/IconPicker.yaml
Normal file
@ -0,0 +1,21 @@
|
||||
name: IconPicker
|
||||
purpose: Search and choose a Bootstrap Icons name.
|
||||
|
||||
properties:
|
||||
value: string; selected icon name without the 'bi-' prefix
|
||||
|
||||
example: |
|
||||
<script>
|
||||
const selectedIcon = 'house'
|
||||
const menu = { icon: 'heart' }
|
||||
const menuSchema = [{ name: 'icon', label: 'Icon', type: 'IconPicker' }]
|
||||
</script>
|
||||
|
||||
<IconPicker $.state.value="selectedIcon"></IconPicker>
|
||||
<AutoForm $.state.data="menu" $.state.schema="menuSchema"></AutoForm>
|
||||
|
||||
events:
|
||||
change: detail is the selected icon name
|
||||
|
||||
related:
|
||||
- ../AutoForm.yaml
|
||||
59
components/form/TagsInput.js
Normal file
59
components/form/TagsInput.js
Normal file
@ -0,0 +1,59 @@
|
||||
/**
|
||||
* TagsInput Component
|
||||
*/
|
||||
globalThis.Component.register('TagsInput', container => {
|
||||
container.state = globalThis.NewState({ value: [] })
|
||||
container.placeholder = container.getAttribute('placeholder') || ''
|
||||
|
||||
const setValue = value => {
|
||||
container.state.value = [...new Set((Array.isArray(value) ? value : []).filter(tag => typeof tag === 'string' && tag.trim()).map(tag => tag.trim()))]
|
||||
}
|
||||
container.addEventListener('bind', event => setValue(event.detail))
|
||||
|
||||
Object.defineProperty(container, 'value', {
|
||||
get: () => container.state.value,
|
||||
set: setValue
|
||||
})
|
||||
|
||||
container._change = () => container.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: [...container.state.value] }))
|
||||
container._removeTag = index => {
|
||||
container.state.value.splice(index, 1)
|
||||
container.state.value = [...container.state.value]
|
||||
container._change()
|
||||
}
|
||||
container._addTag = value => {
|
||||
const tag = value.trim()
|
||||
if (tag && !container.state.value.includes(tag)) {
|
||||
container.state.value = [...container.state.value, tag]
|
||||
container._change()
|
||||
}
|
||||
}
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text">
|
||||
<template $each="this.state.value">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${globalThis.Util.getFunctionBody(function (event) {
|
||||
if (['Backspace', 'Delete'].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
this._removeTag(index)
|
||||
Promise.resolve().then(() => {
|
||||
const buttons = globalThis.$$(this, 'button');
|
||||
(buttons.length > 0 ? buttons[index > 0 ? index - 1 : 0] : globalThis.$(this, 'input')).focus()
|
||||
})
|
||||
}
|
||||
})}" $text="item"></button>
|
||||
</template>
|
||||
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" $placeholder="this.placeholder || '{#new tag name#}'" style="min-width:100px;width:0;outline:none" $onchange="event.stopPropagation()" $oninput="event.stopPropagation()" $onkeydown="${globalThis.Util.getFunctionBody(function (event) {
|
||||
if (event.isComposing) return
|
||||
if (['Enter', ',', ' '].includes(event.key)) {
|
||||
event.preventDefault()
|
||||
const v = thisNode.value.trim()
|
||||
this._addTag(v)
|
||||
thisNode.value = ''
|
||||
}
|
||||
})}">
|
||||
</div>
|
||||
`), globalThis.Util.makeDom(/*html*/`<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>`))
|
||||
|
||||
if (globalThis.AutoForm) {
|
||||
globalThis.AutoForm.register('TagsInput')
|
||||
}
|
||||
1
components/form/TagsInput.min.js
vendored
Normal file
1
components/form/TagsInput.min.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
globalThis.Component.register("TagsInput",e=>{e.state=globalThis.NewState({value:[]}),e.placeholder=e.getAttribute("placeholder")||"";const t=t=>{e.state.value=[...new Set((Array.isArray(t)?t:[]).filter(e=>"string"==typeof e&&e.trim()).map(e=>e.trim()))]};e.addEventListener("bind",e=>t(e.detail)),Object.defineProperty(e,"value",{get:()=>e.state.value,set:t}),e._change=()=>e.dispatchEvent(new CustomEvent("change",{bubbles:!1,detail:[...e.state.value]})),e._removeTag=t=>{e.state.value.splice(t,1),e.state.value=[...e.state.value],e._change()},e._addTag=t=>{const n=t.trim();n&&!e.state.value.includes(n)&&(e.state.value=[...e.state.value,n],e._change())}},globalThis.Util.makeDom(`\n<div class="form-control d-flex flex-wrap gap-1 align-items-center" style="min-height:38px;cursor:text">\n <template $each="this.state.value">\n <button type="button" class="btn btn-sm btn-outline-primary rounded-pill py-0 px-2" $onkeydown="${globalThis.Util.getFunctionBody(function(e){["Backspace","Delete"].includes(e.key)&&(e.preventDefault(),this._removeTag(index),Promise.resolve().then(()=>{const e=globalThis.$$(this,"button");(e.length>0?e[index>0?index-1:0]:globalThis.$(this,"input")).focus()}))})}" $text="item"></button>\n </template>\n <input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" $placeholder="this.placeholder || '{#new tag name#}'" style="min-width:100px;width:0;outline:none" $onchange="event.stopPropagation()" $oninput="event.stopPropagation()" $onkeydown="${globalThis.Util.getFunctionBody(function(e){if(!e.isComposing&&["Enter",","," "].includes(e.key)){e.preventDefault();const t=thisNode.value.trim();this._addTag(t),thisNode.value=""}})}">\n</div>\n`),globalThis.Util.makeDom("<style>TagsInput button:focus {background-color:var(--bs-btn-hover-bg);color:var(--bs-btn-hover-color)}</style>")),globalThis.AutoForm&&globalThis.AutoForm.register("TagsInput");
|
||||
76
components/form/TagsInput.test.html
Normal file
76
components/form/TagsInput.test.html
Normal file
@ -0,0 +1,76 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>components.form.TagsInput tests</title>
|
||||
<script>
|
||||
const tags = ['admin', 'editor']
|
||||
const user = { tags: ['reviewer'] }
|
||||
const userSchema = [{ name: 'tags', label: 'Tags', type: 'TagsInput' }]
|
||||
</script>
|
||||
<script src="../../ui.js" components="TagsInput"></script>
|
||||
</head>
|
||||
<body class="vh-100 overflow-hidden bg-body-tertiary">
|
||||
<div class="container py-3 h-100 d-flex flex-column gap-3">
|
||||
<header class="d-flex align-items-center gap-2 flex-shrink-0">
|
||||
<h1 class="h4 mb-0">components.form.TagsInput</h1>
|
||||
<span id="status" class="badge text-bg-secondary">未测试</span>
|
||||
<button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button>
|
||||
</header>
|
||||
|
||||
<main class="flex-fill overflow-auto">
|
||||
<div class="card"><div class="card-body d-grid gap-3">
|
||||
<TagsInput id="tagsInput" placeholder="Add a role" $.state.value="tags"></TagsInput>
|
||||
<AutoForm id="userForm" $.state.data="user" $.state.schema="userSchema"></AutoForm>
|
||||
</div></div>
|
||||
</main>
|
||||
|
||||
<pre id="result" class="h-25 overflow-auto border rounded bg-body p-2 mb-0 flex-shrink-0 small"></pre>
|
||||
</div>
|
||||
<script>
|
||||
const cases = ['placeholder', 'value', 'add', 'unique', 'remove', 'change', 'autoform']
|
||||
const consoleErrors = []
|
||||
const originalConsoleError = console.error
|
||||
console.error = (...args) => { consoleErrors.push(args.map(String).join(' ')); originalConsoleError(...args) }
|
||||
addEventListener('error', event => consoleErrors.push(String(event.message || event.error || event)))
|
||||
addEventListener('unhandledrejection', event => consoleErrors.push(String(event.reason || event)))
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 80))
|
||||
const assert = (result, feature, condition, message) => { result.assertions++; if (condition) result.passed++; else { result.failed++; result.failures.push({ feature, message }) } }
|
||||
const finish = result => {
|
||||
result.consoleErrors = [...consoleErrors]
|
||||
result.coverage.tested = [...cases]
|
||||
result.coverage.percent = 100
|
||||
result.status = result.failed || result.consoleErrors.length ? 'failed' : 'passed'
|
||||
const status = document.querySelector('#status')
|
||||
status.className = 'badge text-bg-' + (result.status === 'passed' ? 'success' : 'danger')
|
||||
status.textContent = result.status === 'passed' ? '通过' : '失败'
|
||||
document.querySelector('#result').textContent = JSON.stringify(result, null, 2)
|
||||
globalThis.testResult = result
|
||||
return result
|
||||
}
|
||||
globalThis.runTest = async () => {
|
||||
consoleErrors.length = 0
|
||||
const result = { name: 'components.form.TagsInput', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }
|
||||
await tick()
|
||||
const input = document.querySelector('#tagsInput')
|
||||
const textInput = input.querySelector('input')
|
||||
assert(result, 'placeholder', textInput.placeholder === 'Add a role', 'placeholder attribute labels the tag input')
|
||||
assert(result, 'value', input.value.join(',') === 'admin,editor' && input.querySelectorAll('button').length === 2, 'direct value binding renders initial tags')
|
||||
let changed
|
||||
input.addEventListener('change', event => { changed = event.detail }, { once: true })
|
||||
textInput.value = 'owner'; textInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await tick()
|
||||
assert(result, 'add', input.value.includes('owner'), 'Enter adds a trimmed tag')
|
||||
assert(result, 'change', Array.isArray(changed) && changed.includes('owner'), 'change event exposes the complete tag array')
|
||||
textInput.value = 'owner'; textInput.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); await tick()
|
||||
assert(result, 'unique', input.value.filter(tag => tag === 'owner').length === 1, 'duplicate tags are ignored')
|
||||
const owner = [...input.querySelectorAll('button')].find(button => button.textContent === 'owner')
|
||||
owner.dispatchEvent(new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true })); await tick()
|
||||
assert(result, 'remove', !input.value.includes('owner'), 'Backspace removes a focused tag')
|
||||
assert(result, 'autoform', document.querySelector('#userForm TagsInput')?.value.join(',') === 'reviewer', 'AutoForm renders and binds the registered TagsInput field')
|
||||
return finish(result)
|
||||
}
|
||||
document.querySelector('#runAll').onclick = globalThis.runTest
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
23
components/form/TagsInput.yaml
Normal file
23
components/form/TagsInput.yaml
Normal file
@ -0,0 +1,23 @@
|
||||
name: TagsInput
|
||||
purpose: Enter a list of unique string tags; Enter, comma, or space commits a tag.
|
||||
|
||||
attributes:
|
||||
placeholder: string; input placeholder (the built-in default is localized)
|
||||
properties:
|
||||
value: array of strings; current unique tags
|
||||
|
||||
example: |
|
||||
<script>
|
||||
const tags = ['admin', 'editor']
|
||||
const user = { tags: ['admin', 'editor'] }
|
||||
const userSchema = [{ name: 'tags', label: 'Tags', type: 'TagsInput' }]
|
||||
</script>
|
||||
|
||||
<TagsInput placeholder="Add a tag" $.state.value="tags"></TagsInput>
|
||||
<AutoForm $.state.data="user" $.state.schema="userSchema"></AutoForm>
|
||||
|
||||
events:
|
||||
change: detail is the complete updated string array
|
||||
|
||||
related:
|
||||
- ../AutoForm.yaml
|
||||
7303
frameworks/Bootstrap.js
Normal file
7303
frameworks/Bootstrap.js
Normal file
File diff suppressed because one or more lines are too long
1
frameworks/Bootstrap.min.js
vendored
Normal file
1
frameworks/Bootstrap.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
24
frameworks/Bootstrap.yaml
Normal file
24
frameworks/Bootstrap.yaml
Normal file
@ -0,0 +1,24 @@
|
||||
name: Bootstrap
|
||||
|
||||
provided:
|
||||
css: 'Bootstrap.js injects Bootstrap 5 CSS and Bootstrap Icons; do not add another Bootstrap stylesheet or runtime.'
|
||||
bootstrap: 'Native Bootstrap API: Alert, Button, Carousel, Collapse, Dropdown, Modal, Offcanvas, Popover, ScrollSpy, Tab, Toast, Tooltip.'
|
||||
Bootstrap: 'apigo helper API for theme configuration.'
|
||||
|
||||
config:
|
||||
colors: 'Bootstrap.config({ primary: "#6f42c1", success: "#198754" }) patches CSS variables and related component colors.'
|
||||
dark_mode: 'Bootstrap.config({ darkMode: true }) sets data-bs-theme.'
|
||||
bind_dark_mode: 'Bootstrap.config({ bindDarkMode: [LocalStorage, "darkMode"] }) reacts to a state key.'
|
||||
|
||||
rules:
|
||||
- ui.js loads Bootstrap before utilities and components.
|
||||
- Use Bootstrap classes and Icons directly in component markup.
|
||||
- Do not load a second Bootstrap CSS file or runtime.
|
||||
|
||||
examples: |
|
||||
Bootstrap.config({ primary: '#6f42c1' })
|
||||
Bootstrap.config({ bindDarkMode: [LocalStorage, 'darkMode'] })
|
||||
bootstrap.Modal.getOrCreateInstance(document.querySelector('#settings')).show()
|
||||
|
||||
tests:
|
||||
- apigo.cc/web/bootstrap/test/
|
||||
704
frameworks/State.js
Normal file
704
frameworks/State.js
Normal file
@ -0,0 +1,704 @@
|
||||
(function(global, factory) {
|
||||
typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define(["exports"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.ApigoState = global.ApigoState || {}));
|
||||
})(this, function(exports2) {
|
||||
"use strict";
|
||||
var _a, _b;
|
||||
const Util = {
|
||||
clone: (obj) => JSON.parse(JSON.stringify(obj)),
|
||||
base64: (str) => btoa(String.fromCharCode(...new TextEncoder().encode(str))),
|
||||
unbase64: (str) => new TextDecoder().decode(Uint8Array.from(atob(str), (c) => c.charCodeAt(0))),
|
||||
urlbase64: (str) => Util.base64(str).replace(/[+/=]/g, (m) => ({ "+": "-", "/": "", "=": "" })[m]),
|
||||
unurlbase64: (str) => Util.unbase64(str.replace(/[-_.]/g, (m) => ({ "-": "+", "_": "/", ".": "=" })[m]).padEnd(Math.ceil(str.length / 4) * 4, "=")),
|
||||
safeJson: (str) => {
|
||||
try {
|
||||
return JSON.parse(str);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
updateDefaults: (obj, defaults) => {
|
||||
for (const k in defaults) if (obj[k] === void 0) obj[k] = defaults[k];
|
||||
},
|
||||
copyFunction: (toObj, fromObj, ...funcNames) => {
|
||||
funcNames.forEach((name) => toObj[name] = fromObj[name].bind(fromObj));
|
||||
},
|
||||
getFunctionBody: (fn) => {
|
||||
const code = fn.toString();
|
||||
return code.slice(code.indexOf("{") + 1, code.lastIndexOf("}")).trim();
|
||||
},
|
||||
makeDom: (html) => {
|
||||
if (html.includes(">\n")) html = html.replace(/>\s+</g, "><").trim();
|
||||
const node = document.createElement("div");
|
||||
node.innerHTML = html;
|
||||
return node.children[0];
|
||||
},
|
||||
newAvg: () => {
|
||||
let total = 0, count = 0, avg = 0;
|
||||
return {
|
||||
add: (v) => {
|
||||
total += v;
|
||||
count++;
|
||||
return avg = total / count;
|
||||
},
|
||||
get: () => avg,
|
||||
clear: () => {
|
||||
total = 0, count = 0, avg = 0;
|
||||
}
|
||||
};
|
||||
},
|
||||
newTimeCount: () => {
|
||||
let startTime = 0, total = 0, count = 0;
|
||||
return {
|
||||
start: () => startTime = (/* @__PURE__ */ new Date()).getTime(),
|
||||
end: () => {
|
||||
const endTime = (/* @__PURE__ */ new Date()).getTime();
|
||||
const left = endTime - startTime;
|
||||
startTime = endTime;
|
||||
total += left;
|
||||
count++;
|
||||
return left;
|
||||
},
|
||||
avg: () => total / count
|
||||
};
|
||||
}
|
||||
};
|
||||
const $ = (a, b) => b ? a.querySelector(b) : document.querySelector(a);
|
||||
const $$ = (a, b) => b ? a.querySelectorAll(b) : document.querySelectorAll(a);
|
||||
globalThis.Util = Util;
|
||||
globalThis.$ = $;
|
||||
globalThis.$$ = $$;
|
||||
let __activeBinding = null;
|
||||
let __noWriteBack = null;
|
||||
const _setActiveBinding = (val) => __activeBinding = val;
|
||||
const _setNoWriteBack = (val) => __noWriteBack = val;
|
||||
const _notifiers = /* @__PURE__ */ new Set();
|
||||
const _onNotifyUpdate = (fn) => _notifiers.add(fn);
|
||||
function NewState(defaults = {}, getter = null, setter = null) {
|
||||
const _defaults = {};
|
||||
const _stateMappings = /* @__PURE__ */ new Map();
|
||||
const _watchers = /* @__PURE__ */ new Map();
|
||||
const _watchFunc = (k, cb) => {
|
||||
if (!_watchers.has(k)) _watchers.set(k, /* @__PURE__ */ new Set());
|
||||
!cb ? _watchers.get(k).clear() : _watchers.get(k).add(cb);
|
||||
return () => _watchers.get(k).delete(cb);
|
||||
};
|
||||
const _unwatchFunc = (k, cb) => {
|
||||
if (_watchers.has(k)) _watchers.set(k, /* @__PURE__ */ new Set());
|
||||
_watchers.get(k).delete(cb);
|
||||
};
|
||||
const __getter = getter || ((k) => _defaults[k]);
|
||||
const __setter = setter || ((k, v) => _defaults[k] = v);
|
||||
Object.assign(_defaults, defaults);
|
||||
return new Proxy(_defaults, {
|
||||
get(target, key) {
|
||||
if (key === "__watch") return _watchFunc;
|
||||
if (key === "__unwatch") return _unwatchFunc;
|
||||
if (key === "__isProxy") return true;
|
||||
if (__activeBinding) {
|
||||
if (!_stateMappings.has(key)) _stateMappings.set(key, /* @__PURE__ */ new Set());
|
||||
_stateMappings.get(key).add(__activeBinding);
|
||||
if (!__activeBinding.node._states) __activeBinding.node._states = /* @__PURE__ */ new Set();
|
||||
__activeBinding.node._states.add(_stateMappings);
|
||||
}
|
||||
return __getter(key);
|
||||
},
|
||||
set(target, key, value) {
|
||||
if (__getter(key) !== value) {
|
||||
__setter(key, value);
|
||||
}
|
||||
if (_watchers.has(key)) {
|
||||
_watchers.get(key).forEach((cb) => {
|
||||
const r = cb(value);
|
||||
if (r !== void 0) {
|
||||
value = r;
|
||||
target[key] = value;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (_watchers.has(null)) {
|
||||
_watchers.get(null).forEach((cb) => cb(value));
|
||||
}
|
||||
if (_stateMappings.has(key)) {
|
||||
const bindings = _stateMappings.get(key);
|
||||
for (const binding of bindings) {
|
||||
if (!binding.node.isConnected) {
|
||||
bindings.delete(binding);
|
||||
continue;
|
||||
}
|
||||
if (__noWriteBack !== binding.node) {
|
||||
_notifiers.forEach((fn) => fn(binding));
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
globalThis.NewState = NewState;
|
||||
let _hashParams = new URLSearchParams(typeof globalThis !== "undefined" ? ((_b = (_a = globalThis.location) == null ? void 0 : _a.hash) == null ? void 0 : _b.substring(1)) || "" : "");
|
||||
const Hash = NewState({}, (k) => Util.safeJson(_hashParams.get(k)), (k, v) => {
|
||||
const oldStr = _hashParams.get(k);
|
||||
const newStr = v === void 0 ? void 0 : JSON.stringify(v);
|
||||
if (oldStr === newStr || oldStr === null && newStr === void 0) return;
|
||||
v === void 0 ? _hashParams.delete(k) : _hashParams.set(k, newStr);
|
||||
globalThis.location.hash = "#" + _hashParams.toString();
|
||||
});
|
||||
if (typeof globalThis !== "undefined") {
|
||||
globalThis.addEventListener("hashchange", () => {
|
||||
var _a2;
|
||||
const newParams = new URLSearchParams(((_a2 = globalThis.location.hash) == null ? void 0 : _a2.substring(1)) || "");
|
||||
const keys = /* @__PURE__ */ new Set([..._hashParams.keys(), ...newParams.keys()]);
|
||||
_hashParams = newParams;
|
||||
keys.forEach((k) => Hash[k] = Hash[k]);
|
||||
});
|
||||
}
|
||||
const LocalStorage = NewState({}, (k) => Util.safeJson(localStorage.getItem(k)), (k, v) => {
|
||||
const oldStr = localStorage.getItem(k);
|
||||
const newStr = v === void 0 ? void 0 : JSON.stringify(v);
|
||||
if (oldStr === newStr || oldStr === null && newStr === void 0) return;
|
||||
v === void 0 ? localStorage.removeItem(k) : localStorage.setItem(k, newStr);
|
||||
});
|
||||
const State = NewState({
|
||||
exitBlocks: 0
|
||||
});
|
||||
globalThis.Hash = Hash;
|
||||
globalThis.LocalStorage = LocalStorage;
|
||||
globalThis.State = State;
|
||||
let _disableRunCodeError = false;
|
||||
const setDisableRunCodeError = (value) => {
|
||||
_disableRunCodeError = value;
|
||||
};
|
||||
const _fnCache = /* @__PURE__ */ new Map();
|
||||
function _runCode(code, vars, thisObj, extendVars) {
|
||||
const allVars = { ...extendVars || {}, ...vars || {} };
|
||||
const argKeys = Object.keys(allVars);
|
||||
const argValues = Object.values(allVars);
|
||||
const cacheKey = code + argKeys.join(",");
|
||||
try {
|
||||
let fn = _fnCache.get(cacheKey);
|
||||
if (!fn) {
|
||||
fn = new Function("Hash", "LocalStorage", "State", ...argKeys, code);
|
||||
_fnCache.set(cacheKey, fn);
|
||||
}
|
||||
return fn.apply(thisObj, [globalThis.Hash, globalThis.LocalStorage, globalThis.State, ...argValues]);
|
||||
} catch (e) {
|
||||
if (!_disableRunCodeError) console.error(e, extendVars, [code, extendVars, vars, thisObj]);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function _returnCode(code, vars, thisObj, extendVars) {
|
||||
if (code.includes("${")) return _runCode("return `" + code + "`", vars, thisObj, extendVars);
|
||||
else return _runCode("return " + code, vars, thisObj, extendVars);
|
||||
}
|
||||
const _components = /* @__PURE__ */ new Map();
|
||||
const _pendingTemplates = [];
|
||||
const Component = {
|
||||
getTemplate: (name) => document.querySelector(`template[component="${name.toUpperCase()}"]`),
|
||||
register: (name, setupFunc, templateNode = null, ...globalNodes) => {
|
||||
_components.set(name.toUpperCase(), setupFunc);
|
||||
if (document.readyState !== "loading") Component._addTemplate(name, templateNode, globalNodes);
|
||||
else _pendingTemplates.push([name, templateNode, globalNodes]);
|
||||
},
|
||||
exists: (name) => _components.has(name.toUpperCase()),
|
||||
getSetupFunction: (name) => _components.get(name.toUpperCase()),
|
||||
_addTemplate: (name, templateNode, globalNodes) => {
|
||||
if (templateNode) {
|
||||
const template = document.createElement("TEMPLATE");
|
||||
template.setAttribute("component", name.toUpperCase());
|
||||
template.content.appendChild(templateNode);
|
||||
document.body.appendChild(template);
|
||||
}
|
||||
if (globalNodes) globalNodes.forEach((node) => document.body.appendChild(node));
|
||||
},
|
||||
_initPending: () => {
|
||||
_pendingTemplates.forEach(([name, templateNode, globalNodes]) => Component._addTemplate(name, templateNode, globalNodes));
|
||||
_pendingTemplates.length = 0;
|
||||
}
|
||||
};
|
||||
function _mergeNode(from, to, scanObj, exists = {}) {
|
||||
if (from.attributes) {
|
||||
Array.from(from.attributes).forEach((attr) => {
|
||||
if (attr.name === "class") return;
|
||||
if (attr.name === "style") {
|
||||
if (to.hasAttribute("style")) to.setAttribute("style", `${attr.value}; ${to.getAttribute("style")}`);
|
||||
else to.setAttribute("style", attr.value);
|
||||
} else if (to.hasAttribute(attr.name)) {
|
||||
const isClass = ["$class", "st-class"].includes(attr.name);
|
||||
const isStyle = ["$style", "st-style"].includes(attr.name);
|
||||
if (isClass || isStyle) {
|
||||
const oldVal = to.getAttribute(attr.name);
|
||||
const newVal = attr.value;
|
||||
const delimiter = isClass ? " " : "; ";
|
||||
const oldExpr = oldVal.includes("${") ? oldVal : `\${${oldVal}}`;
|
||||
const newExpr = newVal.includes("${") ? newVal : `\${${newVal}}`;
|
||||
to.setAttribute(attr.name, `${newExpr}${delimiter}${oldExpr}`);
|
||||
}
|
||||
} else {
|
||||
to.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
});
|
||||
}
|
||||
const toClassList = [...to.classList];
|
||||
to.className = "";
|
||||
to.classList.add(...from.classList);
|
||||
to.classList.add(...toClassList);
|
||||
const target = to.tagName === "TEMPLATE" ? to.content : to;
|
||||
const sourceNodes = from.tagName === "TEMPLATE" ? from.content.childNodes : from.childNodes;
|
||||
Array.from(sourceNodes).forEach((child) => target.appendChild(child));
|
||||
if (from.tagName && Component.exists(from.tagName)) _makeComponent(from.tagName, to, scanObj, exists);
|
||||
}
|
||||
const _findSlotPlaceholders = (root) => {
|
||||
const placeholders = [...root.querySelectorAll("[slot-id]")];
|
||||
root.querySelectorAll("template").forEach((template) => {
|
||||
placeholders.push(..._findSlotPlaceholders(template.content));
|
||||
});
|
||||
return placeholders;
|
||||
};
|
||||
function _makeComponent(name, node, scanObj, exists = {}) {
|
||||
if (exists[name]) return;
|
||||
exists[name] = true;
|
||||
const parentThis = scanObj.thisObj;
|
||||
if (scanObj.thisObj) {
|
||||
Array.from(node.attributes).forEach((attr) => {
|
||||
if ((attr.name.startsWith("$") || attr.name.startsWith("st-")) && attr.value.includes("this.")) {
|
||||
attr.value = attr.value.replace(/\bthis\./g, "this.parent.");
|
||||
}
|
||||
});
|
||||
}
|
||||
const componentFunc = Component.getSetupFunction(name);
|
||||
const slots = {};
|
||||
Array.from(node.childNodes).forEach((child) => {
|
||||
if (child.nodeType === Node.ELEMENT_NODE && child.hasAttribute("slot")) {
|
||||
slots[child.getAttribute("slot")] = child;
|
||||
child.removeAttribute("slot");
|
||||
}
|
||||
});
|
||||
node.innerHTML = "";
|
||||
node.state = NewState(node.state || {});
|
||||
const template = Component.getTemplate(name);
|
||||
if (template) {
|
||||
const tplnode = template.content.cloneNode(true);
|
||||
if (tplnode.childNodes.length) {
|
||||
const rootNode = tplnode.children[0];
|
||||
if (rootNode) _mergeNode(rootNode, node, scanObj, exists);
|
||||
_findSlotPlaceholders(node).forEach((placeholder) => {
|
||||
const slotName = placeholder.getAttribute("slot-id");
|
||||
if (slots[slotName]) {
|
||||
placeholder.removeAttribute("slot-id");
|
||||
placeholder.innerHTML = "";
|
||||
_mergeNode(slots[slotName], placeholder, scanObj, exists);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (componentFunc) componentFunc(node);
|
||||
node._thisObj = node;
|
||||
if (parentThis && parentThis !== node) node._thisObj.parent = parentThis;
|
||||
}
|
||||
let _translator = (text, args) => {
|
||||
if (!text || typeof text !== "string") return text;
|
||||
return text.replace(/\{(.+?)\}/g, (match, key) => args.hasOwnProperty(key) ? args[key] : match);
|
||||
};
|
||||
const SetTranslator = (fn) => _translator = fn;
|
||||
const _translate = (text) => {
|
||||
if (!text || typeof text !== "string" || !text.includes("{#")) return text;
|
||||
return text.replace(/\{#(.+?)#\}/g, (m, content) => {
|
||||
const parts = content.split("||").map((s) => s.trim());
|
||||
const args = {};
|
||||
if (parts.length > 1) {
|
||||
const matches = parts[0].match(/\{(.+?)\}/g);
|
||||
if (matches) matches.forEach((match, i) => args[match.substring(1, match.length - 1)] = parts[i + 1] || "");
|
||||
}
|
||||
return _translator(parts[0], args);
|
||||
});
|
||||
};
|
||||
if (typeof document !== "undefined") {
|
||||
try {
|
||||
document.createElement("div").setAttribute("$t", "1");
|
||||
} catch (e) {
|
||||
const originalSetAttribute = Element.prototype.setAttribute;
|
||||
Element.prototype.setAttribute = function(name, value) {
|
||||
if (!name.startsWith("$")) return originalSetAttribute.call(this, name, value);
|
||||
return originalSetAttribute.call(this, "st-" + name.substring(1), value);
|
||||
};
|
||||
}
|
||||
}
|
||||
_onNotifyUpdate((binding) => _updateBinding(binding));
|
||||
function _clearRenderedNodes(node) {
|
||||
if (node._renderedNodes) node._renderedNodes.forEach((nodes) => nodes.forEach((child) => {
|
||||
child.remove();
|
||||
if (child._renderedNodes) _clearRenderedNodes(child);
|
||||
}));
|
||||
}
|
||||
function _updateBinding(binding) {
|
||||
const node = binding.node;
|
||||
if (!node.isConnected && node.tagName !== "TEMPLATE") return;
|
||||
_setActiveBinding(binding);
|
||||
let result = binding.exp ? binding.tpl ? _returnCode(binding.tpl, { thisNode: node }, node._thisObj || node, node._ref || null) : null : binding.tpl;
|
||||
if (binding.exp === 2 && typeof result === "string") {
|
||||
try {
|
||||
result = _returnCode(result, { thisNode: node }, node._thisObj || node, node._ref || null);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
_setActiveBinding(null);
|
||||
binding.lastResult = result;
|
||||
if (binding.prop) {
|
||||
const prop = binding.prop;
|
||||
let o = node;
|
||||
for (let i = 0; i < prop.length - 1; i++) {
|
||||
if (!prop[i]) continue;
|
||||
if (o[prop[i]] == null) o[prop[i]] = {};
|
||||
o = o[prop[i]];
|
||||
if (typeof o !== "object") break;
|
||||
}
|
||||
if (typeof o === "object" && o !== null) {
|
||||
const lk = prop[prop.length - 1];
|
||||
if (lk) {
|
||||
if (typeof result === "object" && result != null && !Array.isArray(result) && o[lk] == null) o[lk] = {};
|
||||
const lo = o[lk];
|
||||
if (typeof lo === "object" && lo != null && lo.__watch) Object.assign(lo, result);
|
||||
else {
|
||||
if (o[lk] !== result) o[lk] = result;
|
||||
}
|
||||
} else if (typeof result === "object" && result != null && !Array.isArray(result)) {
|
||||
Object.assign(o, result);
|
||||
}
|
||||
}
|
||||
} else if (binding.attr) {
|
||||
const attr = binding.attr;
|
||||
if (attr === "if") {
|
||||
if (result) {
|
||||
if (!node._renderedNodes || node._renderedNodes.length === 0) {
|
||||
node._children.forEach((child) => {
|
||||
node.parentNode.insertBefore(child, node);
|
||||
child._ref = { ...node._ref };
|
||||
child._thisObj = node._thisObj;
|
||||
});
|
||||
node._renderedNodes = [node._children];
|
||||
}
|
||||
} else {
|
||||
_clearRenderedNodes(node);
|
||||
node._renderedNodes = [];
|
||||
}
|
||||
} else if (attr === "each") {
|
||||
if (result && typeof result === "object") {
|
||||
const asName = node.getAttribute("as") || "item";
|
||||
const indexName = node.getAttribute("index") || "index";
|
||||
const keyName = node.getAttribute("key");
|
||||
let keys, getVal;
|
||||
if (result instanceof Map) {
|
||||
keys = Array.from(result.keys());
|
||||
getVal = (k) => result.get(k);
|
||||
} else if (typeof result[Symbol.iterator] === "function") {
|
||||
const arr = Array.isArray(result) ? result : Array.from(result);
|
||||
keys = new Array(arr.length);
|
||||
for (let i = 0; i < arr.length; i++) keys[i] = i;
|
||||
getVal = (k) => arr[k];
|
||||
} else {
|
||||
keys = Object.keys(result);
|
||||
getVal = (k) => result[k];
|
||||
}
|
||||
if (!node._keyedNodes) node._keyedNodes = /* @__PURE__ */ new Map();
|
||||
const newKeyedNodes = /* @__PURE__ */ new Map();
|
||||
const currentRenderedNodes = [];
|
||||
keys.forEach((k, i) => {
|
||||
const item = getVal(k);
|
||||
const rawKey = keyName ? item && typeof item === "object" ? item[keyName] : item : k;
|
||||
const keyVal = rawKey === void 0 || rawKey === null || newKeyedNodes.has(rawKey) ? `st_key_${i}` : rawKey;
|
||||
let existingNodes = node._keyedNodes.get(keyVal);
|
||||
if (existingNodes) {
|
||||
node._keyedNodes.delete(keyVal);
|
||||
existingNodes.forEach((child) => {
|
||||
node.parentNode.insertBefore(child, node);
|
||||
child._ref[indexName] = k;
|
||||
child._ref[asName] = item;
|
||||
_scanTree(child);
|
||||
});
|
||||
} else {
|
||||
existingNodes = [];
|
||||
node._children.forEach((child) => {
|
||||
const cloned = child.cloneNode(true);
|
||||
cloned._ref = { ...node._ref, [indexName]: k, [asName]: item };
|
||||
cloned._thisObj = node._thisObj;
|
||||
node.parentNode.insertBefore(cloned, node);
|
||||
existingNodes.push(cloned);
|
||||
});
|
||||
}
|
||||
newKeyedNodes.set(keyVal, existingNodes);
|
||||
currentRenderedNodes.push(existingNodes);
|
||||
});
|
||||
node._keyedNodes.forEach((nodes) => nodes.forEach((child) => {
|
||||
_clearRenderedNodes(child);
|
||||
child.remove();
|
||||
}));
|
||||
node._keyedNodes = newKeyedNodes;
|
||||
node._renderedNodes = currentRenderedNodes;
|
||||
} else {
|
||||
_clearRenderedNodes(node);
|
||||
node._renderedNodes = [];
|
||||
}
|
||||
} else if (attr === "bind") {
|
||||
if (["INPUT", "SELECT", "TEXTAREA"].includes(node.tagName) && !node.hasAttribute("autocomplete")) node.setAttribute("autocomplete", "off");
|
||||
if (node.type === "checkbox") {
|
||||
if (node.value !== "on" && !result) {
|
||||
_runCode(`${binding.tpl} = []`, { thisNode: node }, node._thisObj || node, node._ref || {});
|
||||
result = [];
|
||||
}
|
||||
node._checkboxMultiMode = result instanceof Array;
|
||||
const isChecked = result instanceof Array ? result.includes(node.value) : !!result;
|
||||
if (node.checked !== isChecked) node.checked = isChecked;
|
||||
} else if (node.type === "radio") {
|
||||
if (node.checked !== (node.value === String(result ?? ""))) node.checked = node.value === String(result ?? "");
|
||||
} else if ("value" in node && node.type !== "file") {
|
||||
Promise.resolve().then(() => {
|
||||
if (node.value !== String(result ?? "")) node.value = result;
|
||||
});
|
||||
} else if (node.isContentEditable) {
|
||||
if (node.innerHTML !== String(result ?? "")) node.innerHTML = result;
|
||||
}
|
||||
node.dispatchEvent(new CustomEvent("bind", { bubbles: false, detail: result }));
|
||||
} else {
|
||||
if (["checked", "disabled", "readonly"].includes(attr)) result = !!result;
|
||||
if (typeof result === "boolean") result ? node.setAttribute(attr, "") : node.removeAttribute(attr);
|
||||
else if (result !== void 0) {
|
||||
if (typeof result !== "string") result = JSON.stringify(result);
|
||||
if (attr === "text") node.textContent = result ?? "";
|
||||
else if (attr === "html") node.innerHTML = result ?? "";
|
||||
else if (node.tagName === "IMG" && attr === "src" && result.includes(".svg")) node.setAttribute("_src", result ?? "");
|
||||
else if (attr === "class") {
|
||||
if (node._staticClasses === void 0) {
|
||||
node._staticClasses = node.getAttribute("class") || "";
|
||||
}
|
||||
const staticClasses = node._staticClasses;
|
||||
const finalClass = result ? staticClasses ? `${result} ${staticClasses}` : result : staticClasses;
|
||||
node.setAttribute("class", finalClass.trim().replace(/\s+/g, " "));
|
||||
} else if (attr === "style") {
|
||||
if (node._staticStyles === void 0) {
|
||||
node._staticStyles = node.getAttribute("style") || "";
|
||||
}
|
||||
const staticStyles = node._staticStyles;
|
||||
const finalStyle = result ? staticStyles ? `${result}; ${staticStyles}` : result : staticStyles;
|
||||
node.setAttribute("style", finalStyle.trim().replace(/;;+/g, ";").replace(/^;+\s*|;\s*$/g, ""));
|
||||
} else node.setAttribute(attr, result ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function _initBinding(binding) {
|
||||
if (!binding.node._bindings) binding.node._bindings = [];
|
||||
binding.node._bindings.push({ attr: binding.attr, prop: binding.prop, tpl: binding.tpl, exp: binding.exp });
|
||||
_updateBinding(binding);
|
||||
}
|
||||
function _parseNode(node, scanObj) {
|
||||
if (node._bindings) {
|
||||
node._states = /* @__PURE__ */ new Set();
|
||||
node._bindings.forEach((b) => _updateBinding({ node, ...b }));
|
||||
if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false }));
|
||||
return;
|
||||
}
|
||||
if (Component.exists(node.tagName) && !node._componentInitialized) {
|
||||
Array.from(node.attributes).forEach((attr) => {
|
||||
var _a2;
|
||||
if (attr.name.startsWith("$.")) {
|
||||
const realAttrName = attr.name.slice(2);
|
||||
let tpl = _translate(attr.value);
|
||||
if (scanObj.thisObj && tpl.includes("this.")) tpl = tpl.replace(/\bthis\./g, "this.parent.");
|
||||
const result = _returnCode(tpl, { thisNode: node }, { parent: scanObj.thisObj || node }, node._ref || {});
|
||||
let o = node;
|
||||
const prop = realAttrName.split(".");
|
||||
for (let i = 0; i < prop.length - 1; i++) {
|
||||
if (prop[i]) o = o[_a2 = prop[i]] ?? (o[_a2] = {});
|
||||
}
|
||||
o[prop[prop.length - 1]] = result;
|
||||
}
|
||||
});
|
||||
_makeComponent(node.tagName, node, scanObj);
|
||||
$$(node, "[slot-id]").forEach((p) => p.removeAttribute("slot-id"));
|
||||
node._componentInitialized = true;
|
||||
if (!node._thisObj) node._thisObj = node;
|
||||
}
|
||||
if (node.tagName === "TEMPLATE") {
|
||||
node._children = [...node.content.childNodes];
|
||||
if (!node._renderedNodes) node._renderedNodes = [];
|
||||
}
|
||||
let attrs = [];
|
||||
if (node.tagName === "TEMPLATE") {
|
||||
["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"].forEach((n) => node.hasAttribute(n) && attrs.push(node.getAttributeNode(n)));
|
||||
} else {
|
||||
attrs = Array.from(node.attributes).filter((a) => (a.name.startsWith("$") || a.name.startsWith("st-")) && !["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"].includes(a.name) || a.name.includes("."));
|
||||
}
|
||||
if (node._thisObj && scanObj.thisObj && node._thisObj !== scanObj.thisObj) node._thisObj.parent = scanObj.thisObj;
|
||||
if (!node._thisObj) node._thisObj = scanObj.thisObj || null;
|
||||
if (!node._ref) node._ref = scanObj.extendVars || {};
|
||||
node._states = /* @__PURE__ */ new Set();
|
||||
attrs.forEach((attr) => {
|
||||
let exp = 0;
|
||||
if (attr.name.startsWith("$$") || attr.name.startsWith("st-st-")) exp = 2;
|
||||
else if (attr.name.startsWith("$") || attr.name.startsWith("st-")) exp = 1;
|
||||
const realAttrName = exp === 2 ? attr.name.startsWith("$$") ? attr.name.slice(2) : attr.name.slice(6) : exp === 1 ? attr.name.startsWith("$") ? attr.name.slice(1) : attr.name.slice(3) : attr.name;
|
||||
let tpl = attr.value;
|
||||
node.removeAttribute(attr.name);
|
||||
if (realAttrName.startsWith(".")) _initBinding({ node, prop: realAttrName.split("."), tpl, exp });
|
||||
else if (realAttrName.startsWith("on")) {
|
||||
const eventName = realAttrName.slice(2);
|
||||
if (eventName === "update") node._hasOnUpdate = true;
|
||||
if (eventName === "load" && !["BODY", "IMG", "IFRAME"].includes(node.tagName)) node._hasOnLoad = true;
|
||||
if (eventName === "unload" && !["BODY", "IMG", "IFRAME"].includes(node.tagName)) node._hasOnUnload = true;
|
||||
node.addEventListener(eventName, (e) => _runCode(tpl, { event: e, thisNode: node, ...e.detail || {} }, scanObj.thisObj || node, node._ref || {}));
|
||||
} else {
|
||||
if (realAttrName === "bind") {
|
||||
const isTextInput = ["INPUT", "TEXTAREA"].includes(node.tagName) && ["textarea", "text", "password", "email", "number", "search", "url", "tel"].includes(node.type || "text") || node.isContentEditable;
|
||||
node.addEventListener(isTextInput ? "input" : "change", (e) => {
|
||||
let newVal = node.isContentEditable ? e.target.innerHTML : node.type === "checkbox" ? e.target.checked : node.type === "file" ? e.target.files : e.target.value ?? e.detail;
|
||||
_setNoWriteBack(node);
|
||||
setDisableRunCodeError(true);
|
||||
if (node.type === "checkbox" && node._checkboxMultiMode) _runCode(`!!checked ? (!${tpl}.includes(val) && ${tpl}.push(val)) : (index = ${tpl}.indexOf(val), index > -1 && ${tpl}.splice(index, 1))`, { val: node.value, checked: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
|
||||
else _runCode(`${tpl} = val`, { val: newVal, thisNode: node }, scanObj.thisObj || node, node._ref || {});
|
||||
setDisableRunCodeError(false);
|
||||
_setNoWriteBack(null);
|
||||
});
|
||||
} else if (realAttrName === "text" && !tpl) {
|
||||
tpl = node.textContent;
|
||||
node.textContent = "";
|
||||
}
|
||||
if (tpl) {
|
||||
tpl = _translate(tpl);
|
||||
_initBinding({ node, attr: realAttrName, tpl, exp });
|
||||
}
|
||||
}
|
||||
});
|
||||
if (node._hasOnLoad || node._componentInitialized) Promise.resolve().then(() => node.dispatchEvent(new Event("load", { bubbles: false })));
|
||||
if (node._hasOnUpdate) node.dispatchEvent(new Event("update", { bubbles: false }));
|
||||
if (node._thisObj) scanObj.thisObj = node._thisObj;
|
||||
}
|
||||
const _scanTree = (node, scanObj = {}) => {
|
||||
if (node.nodeType === 3) {
|
||||
if (node._stTranslated) return;
|
||||
const translated = _translate(node.textContent);
|
||||
if (translated !== node.textContent) node.textContent = translated;
|
||||
node._stTranslated = true;
|
||||
return;
|
||||
}
|
||||
if (node.nodeType !== 1) return;
|
||||
if (!node._stTranslated) {
|
||||
Array.from(node.attributes).forEach((attr) => {
|
||||
if (!attr.name.startsWith("$") && !attr.name.startsWith("st-") && !attr.name.startsWith(".")) {
|
||||
const translated = _translate(attr.value);
|
||||
if (translated !== attr.value) attr.value = translated;
|
||||
}
|
||||
});
|
||||
node._stTranslated = true;
|
||||
}
|
||||
if (node.tagName !== "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("$each") || node.hasAttribute("st-if") || node.hasAttribute("st-each") || node.hasAttribute("$$if") || node.hasAttribute("$$each") || node.hasAttribute("st-st-if") || node.hasAttribute("st-st-each"))) {
|
||||
const template = document.createElement("TEMPLATE");
|
||||
const attrs = Array.from(node.attributes).filter((attr) => ["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"].includes(attr.name) || (node.hasAttribute("$each") || node.hasAttribute("st-each") || node.hasAttribute("$$each") || node.hasAttribute("st-st-each")) && ["as", "index"].includes(attr.name));
|
||||
attrs.forEach((attr) => {
|
||||
template.setAttribute(attr.name, attr.value);
|
||||
node.removeAttribute(attr.name);
|
||||
});
|
||||
node.parentNode.insertBefore(template, node);
|
||||
template.content.appendChild(node);
|
||||
template._ref = node._ref;
|
||||
_scanTree(template, scanObj);
|
||||
return;
|
||||
}
|
||||
if (node.tagName === "TEMPLATE" && (node.hasAttribute("$if") || node.hasAttribute("st-if") || node.hasAttribute("$$if") || node.hasAttribute("st-st-if")) && (node.hasAttribute("$each") || node.hasAttribute("st-each") || node.hasAttribute("$$each") || node.hasAttribute("st-st-each"))) {
|
||||
const template = document.createElement("TEMPLATE");
|
||||
const attrs = Array.from(node.attributes).filter((attr2) => ["$if", "$each", "st-if", "st-each", "$$if", "$$each", "st-st-if", "st-st-each"].includes(attr2.name));
|
||||
const attr = attrs[attrs.length - 1];
|
||||
template.setAttribute(attr.name, attr.value);
|
||||
node.removeAttribute(attr.name);
|
||||
if (["$each", "st-each", "$$each", "st-st-each"].includes(attr.name)) {
|
||||
Array.from(node.attributes).filter((attr2) => ["as", "index"].includes(attr2.name)).forEach((attr2) => {
|
||||
template.setAttribute(attr2.name, attr2.value);
|
||||
node.removeAttribute(attr2.name);
|
||||
});
|
||||
}
|
||||
Array.from(node.content.childNodes).forEach((child) => template.content.appendChild(child));
|
||||
node.content.appendChild(template);
|
||||
template._ref = node._ref;
|
||||
}
|
||||
if (node.tagName === "IMG" && (node.hasAttribute("src") || node.hasAttribute("_src") || node.hasAttribute("$src"))) {
|
||||
const imgNode = node;
|
||||
Promise.resolve().then(() => {
|
||||
const url = imgNode.getAttribute("_src") || imgNode.getAttribute("src");
|
||||
if (url) fetch(url, { cache: "force-cache" }).then((r) => r.text()).then((svgText) => {
|
||||
const realSvg = new DOMParser().parseFromString(svgText, "image/svg+xml").querySelector("svg");
|
||||
if (realSvg) {
|
||||
Array.from(imgNode.attributes).forEach((attr) => realSvg.setAttribute(attr.name, attr.value));
|
||||
imgNode.replaceWith(realSvg);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
if (node._thisObj !== void 0) scanObj.thisObj = node._thisObj || null;
|
||||
else {
|
||||
let curr = node;
|
||||
while (curr && curr._thisObj === void 0) curr = curr.parentNode;
|
||||
scanObj.thisObj = curr ? curr._thisObj : null;
|
||||
}
|
||||
if (node._ref === void 0) {
|
||||
let curr = node;
|
||||
while (curr && curr._ref === void 0) curr = curr.parentNode;
|
||||
node._ref = curr ? { ...curr._ref } : {};
|
||||
}
|
||||
if (scanObj.extendVars) Object.assign(node._ref, scanObj.extendVars);
|
||||
_parseNode(node, { ...scanObj });
|
||||
const nodes = [...node.childNodes || []];
|
||||
nodes.forEach((child) => _scanTree(child, { thisObj: node._thisObj ?? scanObj.thisObj, extendVars: { ...node._ref } }));
|
||||
};
|
||||
const _unbindTree = (node) => {
|
||||
if (node.nodeType !== 1) return;
|
||||
if (node._hasOnUnload) node.dispatchEvent(new Event("unload", { bubbles: false }));
|
||||
if (node._states) node._states.forEach((mappings) => {
|
||||
for (const [key, bindingSet] of mappings) {
|
||||
for (const binding of bindingSet) {
|
||||
if (binding.node === node) bindingSet.delete(binding);
|
||||
}
|
||||
}
|
||||
});
|
||||
node.childNodes && node.childNodes.forEach((child) => _unbindTree(child));
|
||||
};
|
||||
globalThis.Component = Component;
|
||||
globalThis.SetTranslator = SetTranslator;
|
||||
globalThis.__unsafeRefreshState = _scanTree;
|
||||
if (typeof document !== "undefined") {
|
||||
const init = () => {
|
||||
if (globalThis.Component && globalThis.Component._initPending) {
|
||||
globalThis.Component._initPending();
|
||||
}
|
||||
const htmlNode = document.documentElement;
|
||||
if (!htmlNode.hasAttribute("$data-bs-theme") && !htmlNode.hasAttribute("data-bs-theme")) {
|
||||
htmlNode.setAttribute("$data-bs-theme", "LocalStorage.darkMode?'dark':'light'");
|
||||
}
|
||||
new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
mutation.addedNodes.forEach((newNode) => {
|
||||
if (newNode.isConnected) _scanTree(newNode);
|
||||
});
|
||||
mutation.removedNodes.forEach((oldNode) => _unbindTree(oldNode));
|
||||
});
|
||||
}).observe(document.documentElement, { childList: true, subtree: true });
|
||||
_scanTree(document.documentElement);
|
||||
};
|
||||
if (document.readyState !== "loading") init();
|
||||
else document.addEventListener("DOMContentLoaded", init, true);
|
||||
}
|
||||
const __unsafeRefreshState = _scanTree;
|
||||
exports2.$ = $;
|
||||
exports2.$$ = $$;
|
||||
exports2.Component = Component;
|
||||
exports2.Hash = Hash;
|
||||
exports2.LocalStorage = LocalStorage;
|
||||
exports2.NewState = NewState;
|
||||
exports2.SetTranslator = SetTranslator;
|
||||
exports2.State = State;
|
||||
exports2.Util = Util;
|
||||
exports2.__unsafeRefreshState = __unsafeRefreshState;
|
||||
exports2._returnCode = _returnCode;
|
||||
exports2._runCode = _runCode;
|
||||
exports2.onNotifyUpdate = _onNotifyUpdate;
|
||||
exports2.setActiveBinding = _setActiveBinding;
|
||||
Object.defineProperty(exports2, Symbol.toStringTag, { value: "Module" });
|
||||
});
|
||||
1
frameworks/State.min.js
vendored
Normal file
1
frameworks/State.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
66
frameworks/State.yaml
Normal file
66
frameworks/State.yaml
Normal file
@ -0,0 +1,66 @@
|
||||
name: State
|
||||
|
||||
globals:
|
||||
State: 'Reactive in-memory application state.'
|
||||
Hash: 'Reactive JSON values persisted in location.hash.'
|
||||
LocalStorage: 'Reactive JSON values persisted in localStorage.'
|
||||
NewState: 'NewState(defaults, getter?, setter?) -> reactive object.'
|
||||
|
||||
state:
|
||||
watch: 'state.__watch(key, callback); key=null watches every write.'
|
||||
unwatch: 'state.__unwatch(key, callback).'
|
||||
rules:
|
||||
- Assign through State, Hash, LocalStorage, component.state, or a NewState object to notify bindings.
|
||||
- Hash and LocalStorage values must be JSON-serializable.
|
||||
- Do not use __unsafeRefreshState in application code.
|
||||
|
||||
directives:
|
||||
$text: 'Set textContent. Without a value, evaluates the element text as a template.'
|
||||
$html: Set innerHTML.
|
||||
$class: 'Set dynamic classes while retaining static classes.'
|
||||
$style: 'Set dynamic styles while retaining static styles.'
|
||||
$if: Conditionally render a node or template.
|
||||
$each: 'Render an iterable; supports as and index.'
|
||||
$bind: Two-way bind a form control or component value.
|
||||
$onname: 'Register an event handler; for example $onclick.'
|
||||
$.path: 'Evaluate once during component initialization and assign its property path.'
|
||||
.path: 'Bind an evaluated value to a DOM or component property path.'
|
||||
$$if: 'Evaluate its value once, then evaluate the resulting expression. Use schema expressions such as `$$if="item.if"`.'
|
||||
$$each: 'Two-stage equivalent of $each when the iterable expression itself is stored as data.'
|
||||
|
||||
expression:
|
||||
values: [State, Hash, LocalStorage, top-level const data, this, this.parent, event, thisNode]
|
||||
rules:
|
||||
- 'Define page data with top-level const before DOM parsing, then bind it directly: `$.state.list="users"`.'
|
||||
- Use `this` in a component template and `this.parent` from nested component/template content.
|
||||
- Use template around $if or $each when the surrounding DOM must remain intact.
|
||||
- Use key for large or identity-sensitive $each lists.
|
||||
|
||||
component:
|
||||
register: 'Component.register(name, setup, template?).'
|
||||
instance: 'Every component has this.state; public API is its attributes, state, methods, events, and slots.'
|
||||
slots: '`slot="name"` supplies content; `slot-id="name"` receives it in a component template.'
|
||||
lifecycle:
|
||||
$onload: After initialization.
|
||||
$onupdate: After a bound node updates.
|
||||
$onunload: Before a bound node is removed.
|
||||
rules:
|
||||
- Keep required public behavior declarative; do not expose internal DOM as an application contract.
|
||||
- A bindable component listens for bind and writes event.detail into its state.
|
||||
|
||||
examples: |
|
||||
<script>
|
||||
const users = [{ id: 'ada', name: 'Ada' }]
|
||||
</script>
|
||||
<div $text>Hello ${users[0].name}</div>
|
||||
<List $.state.list="users"></List>
|
||||
<button $onclick="State.dark = !State.dark">Toggle</button>
|
||||
|
||||
Component.register('Counter', container => {
|
||||
container.state.value = 0
|
||||
container.addEventListener('bind', event => { container.state.value = event.detail })
|
||||
}, Util.makeDom('<button $text="this.state.value" $onclick="this.state.value++"></button>'))
|
||||
<!-- <Counter $bind="State.count"></Counter> -->
|
||||
|
||||
tests:
|
||||
- apigo.cc/web/state/test/
|
||||
22
package.json
Normal file
22
package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@apigo.cc/ui",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "node tools/build.cjs",
|
||||
"dev": "node tools/dev.cjs",
|
||||
"test": "node tools/dev.cjs --test"
|
||||
},
|
||||
"files": [
|
||||
"frameworks",
|
||||
"components",
|
||||
"utilities",
|
||||
"ui.js",
|
||||
"ui.test.html",
|
||||
"AI.yaml",
|
||||
"README.md",
|
||||
"README.zh-CN.md",
|
||||
"test",
|
||||
"tools"
|
||||
]
|
||||
}
|
||||
12
playwright.config.cjs
Normal file
12
playwright.config.cjs
Normal file
@ -0,0 +1,12 @@
|
||||
const { defineConfig } = require('@playwright/test');
|
||||
|
||||
module.exports = defineConfig({
|
||||
testDir: './test',
|
||||
testMatch: '**/*.spec.cjs',
|
||||
use: { baseURL: 'http://127.0.0.1:5173' },
|
||||
webServer: {
|
||||
command: 'node tools/serve.cjs',
|
||||
url: 'http://127.0.0.1:5173/ui/test/core.html',
|
||||
reuseExistingServer: !process.env.CI
|
||||
}
|
||||
});
|
||||
24
test/core.html
Normal file
24
test/core.html
Normal file
@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<script src="/ui/ui.js" components="API,AutoForm,List,Modal,Dialog,Nav,Resizer,DatePicker,ColorPicker,IconPicker,TagsInput"></script>
|
||||
</head>
|
||||
<body>
|
||||
<List id="users" $.state.list="State.users" auto-select>
|
||||
<button slot="item-actions" class="edit" $onclick="event.stopPropagation()">Edit</button>
|
||||
</List>
|
||||
<Nav id="mainNav" $.state.list="State.nav"></Nav>
|
||||
<div id="resizeTarget" style="width:100px"></div><Resizer id="resize"></Resizer>
|
||||
<Modal id="dialog" $.state.title="State.title"><div slot="body">Body</div></Modal>
|
||||
<Dialog id="alertDialog"></Dialog>
|
||||
<DatePicker id="datePicker"></DatePicker>
|
||||
<ColorPicker id="colorPicker"></ColorPicker>
|
||||
<TagsInput id="tagsInput"></TagsInput>
|
||||
<script>
|
||||
State.users = [{ id: 'ada', label: 'Ada', summary: 'Admin' }]
|
||||
State.nav = [{ type: 'button', name: 'home', label: 'Home', icon: 'house' }]
|
||||
State.title = 'Dialog'
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
18
test/core.spec.cjs
Normal file
18
test/core.spec.cjs
Normal file
@ -0,0 +1,18 @@
|
||||
const { test, expect } = require('../tools/playwright.cjs');
|
||||
|
||||
test('ui.js loads declared components and their dependencies', async ({ page }) => {
|
||||
await page.goto('/ui/test/core.html');
|
||||
|
||||
await expect(page.locator('#users .list-group-item')).toHaveCount(1);
|
||||
await expect(page.locator('#users .edit')).toHaveCount(1);
|
||||
await expect(page.locator('#mainNav .nav-link')).toHaveText('Home');
|
||||
await expect(page.locator('#dialog .modal-title')).toHaveText('Dialog');
|
||||
await expect(page.locator('#datePicker')).toBeAttached();
|
||||
await expect(page.locator('#colorPicker')).toBeAttached();
|
||||
await expect(page.locator('#tagsInput')).toBeAttached();
|
||||
await expect(page.locator('#alertDialog')).toBeAttached();
|
||||
expect(await page.locator('#users').evaluate(node => typeof node.selectItem === 'function')).toBe(true);
|
||||
expect(await page.locator('#resize').evaluate(node => typeof node.addEventListener === 'function')).toBe(true);
|
||||
expect(await page.locator('body').evaluate(() => typeof globalThis.HTTP === 'object')).toBe(true);
|
||||
expect(await page.evaluate(() => performance.getEntriesByType('resource').some(entry => entry.name.includes('jsdelivr.net')))).toBe(false);
|
||||
});
|
||||
58
tools/build.cjs
Normal file
58
tools/build.cjs
Normal file
@ -0,0 +1,58 @@
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const { readdir, readFile, stat, writeFile } = require('node:fs/promises');
|
||||
const { join, relative, sep } = require('node:path');
|
||||
const terser = require('terser');
|
||||
|
||||
const root = join(__dirname, '..');
|
||||
const publicDirs = ['frameworks', 'utilities', 'components'];
|
||||
|
||||
const version = () => {
|
||||
const tags = execFileSync('git', ['tag', '--sort=-v:refname'], { cwd: root, encoding: 'utf8' })
|
||||
.trim().split(/\s+/).filter(tag => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||
if (!tags.length) return '1.0.0';
|
||||
const [major, minor, patch] = tags[0].slice(1).split('.').map(Number);
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
};
|
||||
|
||||
const filesBelow = async directory => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const results = await Promise.all(entries.map(entry => {
|
||||
const file = join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesBelow(file) : Promise.resolve(entry.name.endsWith('.js') && !entry.name.endsWith('.min.js') ? [file] : []);
|
||||
}));
|
||||
return results.flat();
|
||||
};
|
||||
|
||||
const setVersion = (text, nextVersion) => text
|
||||
.replace(/"version": "\d+\.\d+\.\d+"/, `"version": "${nextVersion}"`)
|
||||
.replace(/@apigo\.cc\/ui@\d+\.\d+\.\d+/g, `@apigo.cc/ui@${nextVersion}`);
|
||||
|
||||
(async () => {
|
||||
const nextVersion = version();
|
||||
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 path = relative(root, file).split(sep).join('/');
|
||||
return [path, Math.floor((await stat(file)).mtimeMs).toString(36)];
|
||||
})));
|
||||
const uiFile = join(root, 'ui.js');
|
||||
const uiSource = await readFile(uiFile, 'utf8');
|
||||
const builtUi = uiSource.replace(
|
||||
/const build = \{ version: '[^']*', files: \{[^\n]*\} \};/,
|
||||
`const build = { version: '${nextVersion}', files: ${JSON.stringify(sourceVersions)} };`
|
||||
);
|
||||
if (builtUi === uiSource && !uiSource.includes('const build =')) throw new Error('ui.js build manifest is missing');
|
||||
await writeFile(uiFile, builtUi);
|
||||
|
||||
const minify = async file => {
|
||||
const output = await terser.minify(await readFile(file, 'utf8'), { compress: true, mangle: true, format: { comments: false } });
|
||||
if (output.error) throw output.error;
|
||||
await writeFile(file.replace(/\.js$/, '.min.js'), output.code + '\n');
|
||||
};
|
||||
await Promise.all([...sources, uiFile].map(minify));
|
||||
|
||||
for (const name of ['package.json', 'README.md', 'README.zh-CN.md', 'AI.yaml']) {
|
||||
const file = join(root, name);
|
||||
await writeFile(file, setVersion(await readFile(file, 'utf8'), nextVersion));
|
||||
}
|
||||
console.log(`Built @apigo.cc/ui@${nextVersion} (${sources.length} module files)`);
|
||||
})();
|
||||
24
tools/dev.cjs
Normal file
24
tools/dev.cjs
Normal file
@ -0,0 +1,24 @@
|
||||
const { execFile, spawn } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
|
||||
const index = process.argv.indexOf('--test');
|
||||
const requested = index === -1 ? '' : process.argv[index + 1];
|
||||
let target = 'ui.test.html';
|
||||
if (requested) {
|
||||
const name = requested.includes('.') ? requested : 'components.' + requested;
|
||||
const parts = name.split('.');
|
||||
if (parts[0] !== 'components' && parts[0] !== 'utilities') throw new Error('Use npm test -- List, form.DatePicker, or components.form.DatePicker');
|
||||
target += '?test=' + encodeURIComponent(name);
|
||||
}
|
||||
|
||||
const server = spawn(process.execPath, [path.join(__dirname, 'serve.cjs')], { stdio: 'inherit' });
|
||||
const url = 'http://127.0.0.1:5173/ui/' + target;
|
||||
const open = () => {
|
||||
const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
||||
const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
|
||||
execFile(command, args, error => { if (error) console.log('Open this URL: ' + url) });
|
||||
};
|
||||
setTimeout(open, 300);
|
||||
const stop = () => server.kill();
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
33
tools/playwright.cjs
Normal file
33
tools/playwright.cjs
Normal file
@ -0,0 +1,33 @@
|
||||
const { chromium, expect, test: base } = require('@playwright/test');
|
||||
|
||||
const endpoint = process.env.CDP_ENDPOINT || 'http://127.0.0.1:9222';
|
||||
const cdpTest = base.extend({
|
||||
browser: [async ({}, use) => {
|
||||
const browser = await chromium.connectOverCDP(endpoint);
|
||||
try {
|
||||
await use(browser);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}, { scope: 'worker' }],
|
||||
context: async ({ browser }, use) => {
|
||||
const context = browser.contexts()[0];
|
||||
if (!context) throw new Error(`No browser context available at ${endpoint}`);
|
||||
await use(context);
|
||||
},
|
||||
page: async ({ context }, use, testInfo) => {
|
||||
const page = await context.newPage();
|
||||
const baseURL = testInfo.project.use.baseURL;
|
||||
if (baseURL) {
|
||||
const goto = page.goto.bind(page);
|
||||
page.goto = (url, options) => goto(new URL(url, baseURL).href, options);
|
||||
}
|
||||
try {
|
||||
await use(page);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = { test: process.env.USE_CDP === '1' ? cdpTest : base, expect };
|
||||
48
tools/serve.cjs
Normal file
48
tools/serve.cjs
Normal file
@ -0,0 +1,48 @@
|
||||
const { createReadStream, existsSync, statSync } = require('node:fs');
|
||||
const { createServer } = require('node:http');
|
||||
const { extname, normalize, resolve } = require('node:path');
|
||||
|
||||
const root = resolve(__dirname, '../..');
|
||||
const port = Number(process.env.PORT || 5173);
|
||||
const types = {
|
||||
'.css': 'text/css',
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.json': 'application/json',
|
||||
'.yaml': 'text/yaml'
|
||||
};
|
||||
|
||||
createServer((request, response) => {
|
||||
const pathname = decodeURIComponent(new URL(request.url, `http://${request.headers.host}`).pathname);
|
||||
if (pathname.startsWith('/__http__/')) {
|
||||
const send = (status, headers, body) => response.writeHead(status, headers).end(body);
|
||||
if (pathname === '/__http__/json') return send(200, { 'Content-Type': 'application/json' }, JSON.stringify({ ok: true, method: request.method }));
|
||||
if (pathname === '/__http__/api-error') return send(200, { 'Content-Type': 'application/json' }, JSON.stringify({ error: 'application failed' }));
|
||||
if (pathname === '/__http__/text') return send(200, { 'Content-Type': 'text/plain' }, 'plain text');
|
||||
if (pathname === '/__http__/head') return send(200, { 'Content-Type': 'text/plain' }, '');
|
||||
if (pathname === '/__http__/headers') return send(200, { 'Content-Type': 'application/json', 'Session-Id': 'session-test', 'Device-Id': 'device-test' }, JSON.stringify({ ok: true }));
|
||||
if (pathname === '/__http__/inspect') {
|
||||
const chunks = [];
|
||||
request.on('data', chunk => chunks.push(chunk));
|
||||
request.on('end', () => send(200, { 'Content-Type': 'application/json' }, JSON.stringify({ method: request.method, headers: request.headers, body: Buffer.concat(chunks).toString() })));
|
||||
return;
|
||||
}
|
||||
if (pathname === '/__http__/error') return send(422, { 'Content-Type': 'application/json' }, JSON.stringify({ error: 'validation failed' }));
|
||||
if (pathname === '/__http__/binary') return send(200, { 'Content-Type': 'application/octet-stream' }, Buffer.from([0, 1, 2, 255]));
|
||||
if (pathname === '/__http__/delay') return setTimeout(() => send(200, { 'Content-Type': 'application/json' }, JSON.stringify({ ok: true })), 100);
|
||||
if (pathname === '/__http__/form') {
|
||||
const chunks = [];
|
||||
request.on('data', chunk => chunks.push(chunk));
|
||||
request.on('end', () => send(200, { 'Content-Type': 'application/json' }, JSON.stringify({ contentType: request.headers['content-type'] || '', body: Buffer.concat(chunks).toString() })));
|
||||
return;
|
||||
}
|
||||
return send(404, { 'Content-Type': 'application/json' }, JSON.stringify({ error: 'not found' }));
|
||||
}
|
||||
const file = resolve(root, `.${normalize(pathname)}`);
|
||||
if (!file.startsWith(root) || !existsSync(file) || statSync(file).isDirectory()) {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { 'Content-Type': types[extname(file)] || 'application/octet-stream' });
|
||||
createReadStream(file).pipe(response);
|
||||
}).listen(port, '127.0.0.1');
|
||||
57
ui.js
Normal file
57
ui.js
Normal file
@ -0,0 +1,57 @@
|
||||
(() => {
|
||||
// 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/API.js":"mrggjm8m","components/AutoForm.js":"mrgoatz5","components/Dialog.js":"mrggjm8m","components/List.js":"mrge46xl","components/Modal.js":"mrg6uq6a","components/Nav.js":"mrg6uq7g","components/Resizer.js":"mrghfzrh","components/Toast.js":"mrghiycy","components/form/ColorPicker.js":"mrgn837c","components/form/DatePicker.js":"mrgn837c","components/form/IconPicker.js":"mrgoatz5","components/form/TagsInput.js":"mrgn837b"} };
|
||||
const script = document.currentScript;
|
||||
if (!script || document.readyState !== 'loading') {
|
||||
throw new Error('ui.js must be loaded by a normal <script> while the document is parsing');
|
||||
}
|
||||
const requested = (script?.getAttribute('components') || '')
|
||||
.split(',')
|
||||
.map(name => name.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const modules = {
|
||||
HTTP: { path: 'utilities/HTTP.js' },
|
||||
VirtualScroll: { path: 'utilities/VirtualScroll.js' },
|
||||
MouseMover: { path: 'utilities/MouseMover.js' },
|
||||
API: { path: 'components/API.js', needs: ['Toast'] },
|
||||
AutoForm: { path: 'components/AutoForm.js', needs: ['Toast'] },
|
||||
List: { path: 'components/List.js', needs: ['VirtualScroll'] },
|
||||
Modal: { path: 'components/Modal.js' },
|
||||
Dialog: { path: 'components/Dialog.js', needs: ['Modal'] },
|
||||
Toast: { path: 'components/Toast.js' },
|
||||
Nav: { path: 'components/Nav.js' },
|
||||
Resizer: { path: 'components/Resizer.js' },
|
||||
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'] }
|
||||
};
|
||||
|
||||
if (!script.hasAttribute('components')) throw new Error('ui.js requires components="..."');
|
||||
|
||||
const base = new URL('.', script.src);
|
||||
const minified = /(?:^|\/)ui\.min\.js$/.test(new URL(script.src).pathname);
|
||||
const sourceUrl = path => {
|
||||
const sourcePath = minified ? path.replace(/\.js$/, '.min.js') : path;
|
||||
const url = new URL(sourcePath, base);
|
||||
const version = build.files[path];
|
||||
if (version) url.searchParams.set('v', version);
|
||||
return url.href;
|
||||
};
|
||||
const writeScript = path => document.write(`<script src="${sourceUrl(path)}"><\/script>`);
|
||||
// Frameworks and utilities are always available to every component.
|
||||
if (!globalThis.Component) writeScript('frameworks/State.js');
|
||||
if (!globalThis.bootstrap) writeScript('frameworks/Bootstrap.js');
|
||||
['HTTP', 'VirtualScroll', 'MouseMover']
|
||||
.forEach(name => writeScript(modules[name].path));
|
||||
const loaded = new Set(['HTTP', 'VirtualScroll', 'MouseMover'])
|
||||
const load = name => {
|
||||
if (loaded.has(name)) return
|
||||
loaded.add(name)
|
||||
const mod = modules[name] || { path: 'components/' + name.split('.').join('/') + '.js' }
|
||||
;(mod.needs || []).forEach(load)
|
||||
writeScript(mod.path)
|
||||
}
|
||||
requested.forEach(load)
|
||||
})();
|
||||
1
ui.min.js
vendored
Normal file
1
ui.min.js
vendored
Normal file
@ -0,0 +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/API.js":"mrggjm8m","components/AutoForm.js":"mrgoatz5","components/Dialog.js":"mrggjm8m","components/List.js":"mrge46xl","components/Modal.js":"mrg6uq6a","components/Nav.js":"mrg6uq7g","components/Resizer.js":"mrghfzrh","components/Toast.js":"mrghiycy","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 s=(t?.getAttribute("components")||"").split(",").map(o=>o.trim()).filter(Boolean),e={HTTP:{path:"utilities/HTTP.js"},VirtualScroll:{path:"utilities/VirtualScroll.js"},MouseMover:{path:"utilities/MouseMover.js"},API:{path:"components/API.js",needs:["Toast"]},AutoForm:{path:"components/AutoForm.js",needs:["Toast"]},List:{path:"components/List.js",needs:["VirtualScroll"]},Modal:{path:"components/Modal.js"},Dialog:{path:"components/Dialog.js",needs:["Modal"]},Toast:{path:"components/Toast.js"},Nav:{path:"components/Nav.js"},Resizer:{path:"components/Resizer.js"},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"]}};if(!t.hasAttribute("components"))throw new Error('ui.js requires components="..."');const r=new URL(".",t.src),n=/(?:^|\/)ui\.min\.js$/.test(new URL(t.src).pathname),m=t=>document.write(`<script src="${(t=>{const s=n?t.replace(/\.js$/,".min.js"):t,e=new URL(s,r),m=o.files[t];return m&&e.searchParams.set("v",m),e.href})(t)}"><\/script>`);globalThis.Component||m("frameworks/State.js"),globalThis.bootstrap||m("frameworks/Bootstrap.js"),["HTTP","VirtualScroll","MouseMover"].forEach(o=>m(e[o].path));const a=new Set(["HTTP","VirtualScroll","MouseMover"]),i=o=>{if(a.has(o))return;a.add(o);const t=e[o]||{path:"components/"+o.split(".").join("/")+".js"};(t.needs||[]).forEach(i),m(t.path)};s.forEach(i)})();
|
||||
60
ui.test.html
Normal file
60
ui.test.html
Normal file
@ -0,0 +1,60 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>@apigo.cc/ui tests</title>
|
||||
<script src="ui.js" components="Resizer"></script>
|
||||
</head>
|
||||
<body class="bg-body-tertiary vh-100 overflow-hidden">
|
||||
<div class="container-fluid py-3 h-100 d-flex">
|
||||
<aside id="testNav" class="flex-shrink-0" style="width:19rem">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex align-items-center"><strong>UI tests</strong><button id="all" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button></div>
|
||||
<div id="tests" class="list-group list-group-flush overflow-auto">
|
||||
<a data-test="utilities.HTTP" href="utilities/HTTP.test.html" target="testFrame" class="list-group-item list-group-item-action d-flex align-items-center gap-2"><span class="status badge rounded-pill text-bg-secondary">—</span>HTTP</a>
|
||||
<a data-test="components.API" href="components/API.test.html" target="testFrame" class="list-group-item list-group-item-action d-flex align-items-center gap-2"><span class="status badge rounded-pill text-bg-secondary">—</span>API</a>
|
||||
<a data-test="components.AutoForm" href="components/AutoForm.test.html" target="testFrame" class="list-group-item list-group-item-action d-flex align-items-center gap-2"><span class="status badge rounded-pill text-bg-secondary">—</span>AutoForm</a>
|
||||
<a data-test="components.form.DatePicker" href="components/form/DatePicker.test.html" target="testFrame" class="list-group-item list-group-item-action d-flex align-items-center gap-2 ps-4"><span class="status badge rounded-pill text-bg-secondary">—</span>form.DatePicker</a>
|
||||
<a data-test="components.form.ColorPicker" href="components/form/ColorPicker.test.html" target="testFrame" class="list-group-item list-group-item-action d-flex align-items-center gap-2 ps-4"><span class="status badge rounded-pill text-bg-secondary">—</span>form.ColorPicker</a>
|
||||
<a data-test="components.form.IconPicker" href="components/form/IconPicker.test.html" target="testFrame" class="list-group-item list-group-item-action d-flex align-items-center gap-2 ps-4"><span class="status badge rounded-pill text-bg-secondary">—</span>form.IconPicker</a>
|
||||
<a data-test="components.form.TagsInput" href="components/form/TagsInput.test.html" target="testFrame" class="list-group-item list-group-item-action d-flex align-items-center gap-2 ps-4"><span class="status badge rounded-pill text-bg-secondary">—</span>form.TagsInput</a>
|
||||
<a data-test="components.Toast" href="components/Toast.test.html" target="testFrame" class="list-group-item list-group-item-action d-flex align-items-center gap-2"><span class="status badge rounded-pill text-bg-secondary">—</span>Toast</a>
|
||||
<a data-test="components.Resizer" href="components/Resizer.test.html" target="testFrame" class="list-group-item list-group-item-action d-flex align-items-center gap-2"><span class="status badge rounded-pill text-bg-secondary">—</span>Resizer</a>
|
||||
<a data-test="components.List" href="components/List.test.html" target="testFrame" class="list-group-item list-group-item-action d-flex align-items-center gap-2"><span class="status badge rounded-pill text-bg-secondary">—</span>List</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<Resizer min="224" max="640"></Resizer>
|
||||
<main class="flex-fill min-w-0 ms-3"><div class="card h-100"><iframe id="testFrame" name="testFrame" class="card-body p-0 border-0 w-100 h-100"></iframe></div></main>
|
||||
</div>
|
||||
<script>
|
||||
const rows = [...document.querySelectorAll('[data-test]')]
|
||||
const frame = document.querySelector('#testFrame')
|
||||
let activeRow
|
||||
const select = row => { activeRow = row; rows.forEach(item => item.classList.toggle('active', item === row)) }
|
||||
const setStatus = (row, status) => { const badge = row.querySelector('.status'); badge.className = 'status badge rounded-pill text-bg-' + ({ passed: 'success', failed: 'danger', running: 'warning' }[status] || 'secondary'); badge.textContent = ({ passed: '✓', failed: '!', running: '…' }[status] || '—') }
|
||||
const run = async row => {
|
||||
select(row); setStatus(row, 'running')
|
||||
frame.src = row.getAttribute('href')
|
||||
await new Promise(resolve => { frame.onload = resolve })
|
||||
const errors = []
|
||||
const onError = event => { if (event instanceof ErrorEvent) errors.push(String(event.message || event.error || event)) }
|
||||
frame.contentWindow.addEventListener('error', onError)
|
||||
frame.contentWindow.addEventListener('unhandledrejection', onError)
|
||||
const result = await frame.contentWindow.runTest()
|
||||
frame.contentWindow.removeEventListener('error', onError)
|
||||
frame.contentWindow.removeEventListener('unhandledrejection', onError)
|
||||
result.consoleErrors = [...(result.consoleErrors || []), ...errors]
|
||||
if (result.consoleErrors.length) result.status = 'failed'
|
||||
setStatus(row, result.status)
|
||||
return result
|
||||
}
|
||||
rows.forEach(row => row.onclick = event => { event.preventDefault(); run(row) })
|
||||
document.querySelector('#all').onclick = async () => { for (const row of rows) await run(row) }
|
||||
const selected = new URLSearchParams(location.search).get('test')
|
||||
const selectedTestRow = rows.find(row => row.dataset.test === selected)
|
||||
if (selectedTestRow) run(selectedTestRow)
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
72
utilities/HTTP.js
Normal file
72
utilities/HTTP.js
Normal file
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* HTTP Module
|
||||
*/
|
||||
const HTTP = {
|
||||
get: ({ url, ...opt }) => HTTP.request({ url, method: 'GET', ...opt }),
|
||||
post: ({ url, data, ...opt }) => HTTP.request({ url, method: 'POST', data, ...opt }),
|
||||
put: ({ url, data, ...opt }) => HTTP.request({ url, method: 'PUT', data, ...opt }),
|
||||
delete: ({ url, ...opt }) => HTTP.request({ url, method: 'DELETE', ...opt }),
|
||||
head: ({ url, ...opt }) => HTTP.request({ url, method: 'HEAD', ...opt }),
|
||||
request: async ({ url, method = 'POST', data = undefined, headers = {}, responseType, timeout = 10000 }) => {
|
||||
method = method.toUpperCase()
|
||||
const options = { method, signal: AbortSignal.timeout?.(timeout) }
|
||||
if (data !== undefined && method !== 'GET' && method !== 'HEAD') {
|
||||
if (data instanceof HTMLFormElement) data = new FormData(data)
|
||||
if (data && typeof data === 'object' && !(data instanceof FormData) && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data)) && Object.values(data).some(v => v instanceof File || v instanceof Blob || v instanceof FileList || (Array.isArray(v) && v.some(i => i instanceof File || i instanceof Blob)))) {
|
||||
const fd = new FormData()
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
if (v instanceof FileList || Array.isArray(v)) Array.from(v).forEach(item => fd.append(k, item))
|
||||
else if (v !== undefined && v !== null) fd.append(k, v)
|
||||
}
|
||||
data = fd
|
||||
}
|
||||
if (data instanceof FormData) {
|
||||
delete headers['Content-Type']
|
||||
} else if (typeof data !== 'string' && !(data instanceof ArrayBuffer || ArrayBuffer.isView(data))) {
|
||||
data = JSON.stringify(data)
|
||||
if (!headers['Content-Type']) headers['Content-Type'] = 'application/json'
|
||||
}
|
||||
options.body = data
|
||||
}
|
||||
for (const name of HTTP.keepHeaders) {
|
||||
if (HTTP._keepHeadersCache[name] && !headers[name]) headers[name] = HTTP._keepHeadersCache[name]
|
||||
}
|
||||
if (Object.keys(headers).length) options.headers = headers
|
||||
const response = { error: null, ok: null, status: 0, headers: {}, responseType: '', result: null }
|
||||
try {
|
||||
const resp = await fetch(url, options)
|
||||
Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) })
|
||||
for (const name of HTTP.keepHeaders) {
|
||||
const val = resp.headers.get(name)
|
||||
if (val) {
|
||||
HTTP._keepHeadersCache[name] = val
|
||||
try { localStorage.setItem('_http_keep_' + name, val); } catch (e) {}
|
||||
}
|
||||
}
|
||||
if (!responseType) {
|
||||
const contentType = resp.headers.get('Content-Type') || ''
|
||||
if (contentType.includes('application/json')) responseType = 'json'
|
||||
else if (/image|video|audio|pdf|zip|octet-stream/.test(contentType)) responseType = 'binary'
|
||||
else responseType = 'text'
|
||||
response.responseType = responseType
|
||||
}
|
||||
if (response.ok === false) response.error = (response.statusText || 'HTTP ' + response.status + ' error') + ' for ' + url
|
||||
if (responseType === 'json') response.result = await resp.json()
|
||||
else response.result = (responseType === 'binary') ? await resp.arrayBuffer() : await resp.text()
|
||||
} catch (err) {
|
||||
Object.assign(response, { error: err.message || String(err), ok: false })
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
HTTP.keepHeaders = ['Session-Id', 'Device-Id'];
|
||||
HTTP._keepHeadersCache = {};
|
||||
for (const name of HTTP.keepHeaders) {
|
||||
try {
|
||||
const val = localStorage.getItem('_http_keep_' + name);
|
||||
if (val !== null) HTTP._keepHeadersCache[name] = val;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
globalThis.HTTP = HTTP;
|
||||
1
utilities/HTTP.min.js
vendored
Normal file
1
utilities/HTTP.min.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
const HTTP={get:({url:e,...t})=>HTTP.request({url:e,method:"GET",...t}),post:({url:e,data:t,...r})=>HTTP.request({url:e,method:"POST",data:t,...r}),put:({url:e,data:t,...r})=>HTTP.request({url:e,method:"PUT",data:t,...r}),delete:({url:e,...t})=>HTTP.request({url:e,method:"DELETE",...t}),head:({url:e,...t})=>HTTP.request({url:e,method:"HEAD",...t}),request:async({url:e,method:t="POST",data:r,headers:a={},responseType:s,timeout:o=1e4})=>{const n={method:t=t.toUpperCase(),signal:AbortSignal.timeout?.(o)};if(void 0!==r&&"GET"!==t&&"HEAD"!==t){if(r instanceof HTMLFormElement&&(r=new FormData(r)),r&&"object"==typeof r&&!(r instanceof FormData)&&!(r instanceof ArrayBuffer||ArrayBuffer.isView(r))&&Object.values(r).some(e=>e instanceof File||e instanceof Blob||e instanceof FileList||Array.isArray(e)&&e.some(e=>e instanceof File||e instanceof Blob))){const e=new FormData;for(const[t,a]of Object.entries(r))a instanceof FileList||Array.isArray(a)?Array.from(a).forEach(r=>e.append(t,r)):null!=a&&e.append(t,a);r=e}r instanceof FormData?delete a["Content-Type"]:"string"==typeof r||r instanceof ArrayBuffer||ArrayBuffer.isView(r)||(r=JSON.stringify(r),a["Content-Type"]||(a["Content-Type"]="application/json")),n.body=r}for(const e of HTTP.keepHeaders)HTTP._keepHeadersCache[e]&&!a[e]&&(a[e]=HTTP._keepHeadersCache[e]);Object.keys(a).length&&(n.headers=a);const i={error:null,ok:null,status:0,headers:{},responseType:"",result:null};try{const t=await fetch(e,n);Object.assign(i,{ok:t.ok,status:t.status,headers:Object.fromEntries(t.headers.entries())});for(const e of HTTP.keepHeaders){const r=t.headers.get(e);if(r){HTTP._keepHeadersCache[e]=r;try{localStorage.setItem("_http_keep_"+e,r)}catch(e){}}}if(!s){const e=t.headers.get("Content-Type")||"";s=e.includes("application/json")?"json":/image|video|audio|pdf|zip|octet-stream/.test(e)?"binary":"text",i.responseType=s}!1===i.ok&&(i.error=(i.statusText||"HTTP "+i.status+" error")+" for "+e),i.result="json"===s?await t.json():"binary"===s?await t.arrayBuffer():await t.text()}catch(e){Object.assign(i,{error:e.message||String(e),ok:!1})}return i},keepHeaders:["Session-Id","Device-Id"],_keepHeadersCache:{}};for(const e of HTTP.keepHeaders)try{const t=localStorage.getItem("_http_keep_"+e);null!==t&&(HTTP._keepHeadersCache[e]=t)}catch(e){}globalThis.HTTP=HTTP;
|
||||
39
utilities/HTTP.test.html
Normal file
39
utilities/HTTP.test.html
Normal file
@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>utilities.HTTP tests</title>
|
||||
<script src="../ui.js" components=""></script>
|
||||
</head>
|
||||
<body>
|
||||
<button id="runAll" type="button">测试全部</button>
|
||||
<pre id="result"></pre>
|
||||
<script>
|
||||
const cases = ['request-default-post', 'get', 'put', 'delete', 'head', 'json-body', 'string-body', 'arraybuffer-body', 'form', 'file', 'text', 'binary', 'response-type', 'http-error', 'timeout', 'keep-headers']
|
||||
const assert = (testResult, feature, condition, message) => { testResult.assertions++; if (condition) testResult.passed++; else { testResult.failed++; testResult.failures.push({ feature, message }) } }
|
||||
const newResult = () => ({ name: 'utilities.HTTP', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } })
|
||||
const inspect = options => HTTP.request({ url: '/__http__/inspect', ...options })
|
||||
async function verify(feature, testResult) {
|
||||
if (feature === 'request-default-post') { const response = await inspect({}); assert(testResult, feature, response.ok && response.result.method === 'POST', 'request defaults to POST') }
|
||||
if (feature === 'get') { const response = await HTTP.get({ url: '/__http__/json' }); assert(testResult, feature, response.ok && response.result.method === 'GET' && response.responseType === 'json', 'get returns inferred JSON') }
|
||||
if (feature === 'put') { const response = await HTTP.put({ url: '/__http__/inspect', data: { name: 'Ada' } }); assert(testResult, feature, response.ok && response.result.method === 'PUT', 'put sends PUT') }
|
||||
if (feature === 'delete') { const response = await HTTP.delete({ url: '/__http__/inspect' }); assert(testResult, feature, response.ok && response.result.method === 'DELETE', 'delete sends DELETE') }
|
||||
if (feature === 'head') { const response = await HTTP.head({ url: '/__http__/head', responseType: 'text' }); assert(testResult, feature, response.ok && response.result === '', 'head sends HEAD and accepts an empty text response') }
|
||||
if (feature === 'json-body') { const response = await inspect({ data: { name: 'Ada' } }); assert(testResult, feature, response.ok && response.result.headers['content-type'].includes('application/json') && response.result.body.includes('Ada'), 'plain object becomes JSON') }
|
||||
if (feature === 'string-body') { const response = await inspect({ data: 'raw body', headers: { 'Content-Type': 'text/plain' } }); assert(testResult, feature, response.ok && response.result.body === 'raw body' && response.result.headers['content-type'] === 'text/plain', 'string body and custom headers are preserved') }
|
||||
if (feature === 'arraybuffer-body') { const response = await inspect({ data: new Uint8Array([1, 2]) }); assert(testResult, feature, response.ok && response.result.body.length === 2, 'ArrayBuffer views are sent without JSON conversion') }
|
||||
if (feature === 'form') { const form = document.createElement('form'); const input = document.createElement('input'); input.name = 'name'; input.value = 'Ada'; form.appendChild(input); const response = await HTTP.post({ url: '/__http__/form', data: form }); assert(testResult, feature, response.ok && response.result.contentType.includes('multipart/form-data') && response.result.body.includes('Ada'), 'HTMLFormElement becomes FormData') }
|
||||
if (feature === 'file') { const response = await HTTP.post({ url: '/__http__/form', data: { avatar: new File(['avatar'], 'avatar.txt', { type: 'text/plain' }) } }); assert(testResult, feature, response.ok && response.result.contentType.includes('multipart/form-data') && response.result.body.includes('avatar.txt'), 'File object becomes FormData') }
|
||||
if (feature === 'text') { const response = await HTTP.get({ url: '/__http__/text' }); assert(testResult, feature, response.ok && response.responseType === 'text' && response.result === 'plain text', 'text Content-Type returns text') }
|
||||
if (feature === 'binary') { const response = await HTTP.get({ url: '/__http__/binary' }); assert(testResult, feature, response.ok && response.responseType === 'binary' && response.result.byteLength === 4, 'binary Content-Type returns ArrayBuffer') }
|
||||
if (feature === 'response-type') { const response = await HTTP.get({ url: '/__http__/json', responseType: 'text' }); assert(testResult, feature, response.ok && typeof response.result === 'string' && response.result.includes('GET'), 'responseType overrides Content-Type inference') }
|
||||
if (feature === 'http-error') { const originalFetch = globalThis.fetch; globalThis.fetch = async () => new Response(JSON.stringify({ error: 'invalid' }), { status: 422, headers: { 'Content-Type': 'application/json' } }); const response = await HTTP.get({ url: '/mock-error' }); globalThis.fetch = originalFetch; assert(testResult, feature, !response.ok && response.status === 422 && response.result.error === 'invalid' && response.error.includes('422'), 'HTTP failure resolves with response status, body, and error') }
|
||||
if (feature === 'timeout') { const response = await HTTP.get({ url: '/__http__/delay', timeout: 10 }); assert(testResult, feature, !response.ok && response.error, 'timeout resolves with ok false and an error') }
|
||||
if (feature === 'keep-headers') { await HTTP.get({ url: '/__http__/headers' }); const response = await HTTP.get({ url: '/__http__/inspect' }); assert(testResult, feature, response.result.headers['session-id'] === 'session-test' && response.result.headers['device-id'] === 'device-test', 'Session-Id and Device-Id persist into later requests') }
|
||||
}
|
||||
function finish(testResult) { testResult.coverage.tested = [...cases]; testResult.coverage.percent = 100; testResult.status = testResult.failed ? 'failed' : 'passed'; document.querySelector('#result').textContent = JSON.stringify(testResult, null, 2); window.testResult = testResult; return testResult }
|
||||
window.runTest = async () => { const testResult = newResult(); for (const feature of cases) await verify(feature, testResult); return finish(testResult) }
|
||||
document.querySelector('#runAll').onclick = window.runTest
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
50
utilities/HTTP.yaml
Normal file
50
utilities/HTTP.yaml
Normal file
@ -0,0 +1,50 @@
|
||||
name: HTTP
|
||||
|
||||
methods:
|
||||
request:
|
||||
signature: HTTP.request(options)
|
||||
get:
|
||||
signature: HTTP.get(options)
|
||||
post:
|
||||
signature: HTTP.post(options)
|
||||
put:
|
||||
signature: HTTP.put(options)
|
||||
delete:
|
||||
signature: HTTP.delete(options)
|
||||
head:
|
||||
signature: HTTP.head(options)
|
||||
|
||||
properties:
|
||||
keepHeaders: 'Header names retained from responses and injected into later requests. Default: [Session-Id, Device-Id].'
|
||||
|
||||
options:
|
||||
url: Required request URL.
|
||||
method: HTTP method. request defaults to POST.
|
||||
data: Object, string, ArrayBuffer, FormData, or HTMLFormElement.
|
||||
headers: Request headers.
|
||||
responseType: json, text, or binary. Defaults from Content-Type.
|
||||
timeout: Milliseconds. Default is 10000.
|
||||
|
||||
response:
|
||||
fields: [ok, status, headers, responseType, result, error]
|
||||
behavior: HTTP failures resolve with ok false and error. Transport failures also resolve with ok false.
|
||||
|
||||
rules:
|
||||
- Plain object request data is sent as JSON unless it contains File, Blob, or FileList values.
|
||||
- File-like values are converted to FormData.
|
||||
- Do not set Content-Type for FormData.
|
||||
- Session-Id and Device-Id response headers are retained by default; extend HTTP.keepHeaders to retain additional headers.
|
||||
|
||||
examples:
|
||||
get_json: |
|
||||
const response = await HTTP.get({ url: '/api/users' })
|
||||
if (response.ok) render(response.result)
|
||||
post_json: |
|
||||
const response = await HTTP.post({ url: '/api/users', data: { name: 'Ada' } })
|
||||
upload: |
|
||||
const response = await HTTP.post({ url: '/api/upload', data: document.querySelector('#form') })
|
||||
api_component: |
|
||||
<API id="usersApi" auto $.request="{ url: '/api/users', method: 'GET' }"></API>
|
||||
|
||||
tests:
|
||||
- HTTP.test.html
|
||||
30
utilities/MouseMover.js
Normal file
30
utilities/MouseMover.js
Normal file
@ -0,0 +1,30 @@
|
||||
/**
|
||||
* MouseMover Utility Module
|
||||
*/
|
||||
let _mouseMoverMoving = false
|
||||
let _mouseMoverPos = {}
|
||||
let _mouseMoverEvents = {}
|
||||
|
||||
const MouseMover = {
|
||||
start: (event, { onmousemove, onmouseup }) => {
|
||||
_mouseMoverPos = { x: event.clientX, y: event.clientY, w: 0, h: 0 }
|
||||
_mouseMoverEvents = { onmousemove, onmouseup }
|
||||
_mouseMoverMoving = true
|
||||
},
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('mouseup', event => {
|
||||
if (!_mouseMoverMoving) return
|
||||
_mouseMoverMoving = false
|
||||
_mouseMoverEvents.onmouseup?.({ event, ..._mouseMoverPos })
|
||||
})
|
||||
document.addEventListener('mousemove', event => {
|
||||
if (!_mouseMoverMoving) return
|
||||
_mouseMoverPos.w = event.clientX - _mouseMoverPos.x
|
||||
_mouseMoverPos.h = event.clientY - _mouseMoverPos.y
|
||||
_mouseMoverEvents.onmousemove?.({ event, ..._mouseMoverPos })
|
||||
})
|
||||
}
|
||||
|
||||
globalThis.MouseMover = MouseMover;
|
||||
1
utilities/MouseMover.min.js
vendored
Normal file
1
utilities/MouseMover.min.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
let _mouseMoverMoving=!1,_mouseMoverPos={},_mouseMoverEvents={};const MouseMover={start:(e,{onmousemove:o,onmouseup:s})=>{_mouseMoverPos={x:e.clientX,y:e.clientY,w:0,h:0},_mouseMoverEvents={onmousemove:o,onmouseup:s},_mouseMoverMoving=!0}};"undefined"!=typeof document&&(document.addEventListener("mouseup",e=>{_mouseMoverMoving&&(_mouseMoverMoving=!1,_mouseMoverEvents.onmouseup?.({event:e,..._mouseMoverPos}))}),document.addEventListener("mousemove",e=>{_mouseMoverMoving&&(_mouseMoverPos.w=e.clientX-_mouseMoverPos.x,_mouseMoverPos.h=e.clientY-_mouseMoverPos.y,_mouseMoverEvents.onmousemove?.({event:e,..._mouseMoverPos}))})),globalThis.MouseMover=MouseMover;
|
||||
99
utilities/VirtualScroll.js
Normal file
99
utilities/VirtualScroll.js
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* VirtualScroll Utility Module
|
||||
*/
|
||||
const VirtualScroll = (options = {}) => {
|
||||
const itemHeights = new Map()
|
||||
const groupHeights = new Map()
|
||||
let groupItemCount = 1
|
||||
const avg = globalThis.Util.newAvg()
|
||||
let padTop = 0, rowGap = 0, topMargin = 0, itemMarginTop = null, itemMarginBottom = null, listInited = false
|
||||
|
||||
const providedItemHeight = options.itemHeight || null;
|
||||
|
||||
return {
|
||||
reset: (list, container) => {
|
||||
listInited = false; itemHeights.clear(); groupHeights.clear(); avg.clear(); topMargin = 0; itemMarginTop = null; itemMarginBottom = null;
|
||||
if (!list?.length) return [];
|
||||
const size = list.length; groupItemCount = Math.ceil(Math.sqrt(size)) || 10;
|
||||
const style = window.getComputedStyle(container);
|
||||
padTop = parseFloat(style.paddingTop) || 0; rowGap = parseFloat(style.rowGap) || 0;
|
||||
const visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / (providedItemHeight || 32)));
|
||||
return list.slice(0, Math.min(visibleCount * 3, size));
|
||||
},
|
||||
init: (list, refreshCallback) => {
|
||||
if (listInited) return;
|
||||
const size = list.length;
|
||||
let defaultHeight = providedItemHeight || avg.get() || 32;
|
||||
if (size > 0 && typeof list[0] === 'object' && list[0] !== null && list[0]._itemHeight) {
|
||||
defaultHeight = list[0]._itemHeight;
|
||||
}
|
||||
avg.add(defaultHeight);
|
||||
if (itemMarginTop === null) { itemMarginTop = 0; itemMarginBottom = 0; }
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (!itemHeights.has(i)) {
|
||||
const ih = (typeof list[i] === 'object' && list[i] !== null && list[i]._itemHeight) ? list[i]._itemHeight : defaultHeight;
|
||||
itemHeights.set(i, ih);
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < size; i += groupItemCount) groupHeights.set(i, Math.min(groupItemCount, size - i) * defaultHeight);
|
||||
listInited = true; refreshCallback();
|
||||
},
|
||||
update: (absoluteIndex, node) => {
|
||||
if (node.offsetHeight === 0) return;
|
||||
if (itemMarginTop === null) {
|
||||
const style = window.getComputedStyle(node);
|
||||
itemMarginTop = parseFloat(style.marginTop) || 0; itemMarginBottom = parseFloat(style.marginBottom) || 0;
|
||||
}
|
||||
if (absoluteIndex === 0 && !topMargin) topMargin = itemMarginTop;
|
||||
const newHeight = node.offsetHeight + itemMarginTop + itemMarginBottom + rowGap;
|
||||
const oldHeight = itemHeights.get(absoluteIndex);
|
||||
if (newHeight !== oldHeight) {
|
||||
itemHeights.set(absoluteIndex, newHeight); avg.add(newHeight);
|
||||
const offset = newHeight - (oldHeight || 0), groupIndex = absoluteIndex - (absoluteIndex % groupItemCount);
|
||||
if (groupHeights.has(groupIndex)) groupHeights.set(groupIndex, groupHeights.get(groupIndex) + offset);
|
||||
}
|
||||
},
|
||||
calc: (container, list) => {
|
||||
if (!listInited || !list) return null;
|
||||
const size = list.length;
|
||||
const avgVal = Math.max(16, avg.get() || 32);
|
||||
let visibleCount = Math.max(10, Math.ceil((container.clientHeight || 100) / avgVal));
|
||||
let prev = padTop + topMargin + rowGap, post = 0, status = 0, listStartIndex = 0, listEndIndex = 0;
|
||||
let renderedList = [];
|
||||
const scrollTop = container.scrollTop;
|
||||
let loopCount = 0;
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
if (++loopCount > size * 2) throw new Error('VirtualScroll infinite loop');
|
||||
if (status === 0) {
|
||||
const gh = groupHeights.get(i);
|
||||
if (gh && prev + gh <= scrollTop && (i + groupItemCount < size)) {
|
||||
prev += gh; i += groupItemCount - 1;
|
||||
} else {
|
||||
const ih = itemHeights.get(i);
|
||||
if (prev + ih <= scrollTop && i < size - 1) {
|
||||
prev += ih;
|
||||
} else {
|
||||
status = 1;
|
||||
let visibleStartIndex = Math.max(0, i);
|
||||
listStartIndex = Math.max(0, visibleStartIndex - visibleCount);
|
||||
listEndIndex = Math.min(listStartIndex + visibleCount * 3, size);
|
||||
i = listEndIndex - 1;
|
||||
renderedList = list.slice(listStartIndex, listEndIndex);
|
||||
for (let j = listStartIndex; j < visibleStartIndex; j++) prev -= itemHeights.get(j);
|
||||
}
|
||||
}
|
||||
} else if (status === 1) {
|
||||
const gh = groupHeights.get(i);
|
||||
if (gh) { post += gh; i += groupItemCount - 1; }
|
||||
else post += itemHeights.get(i);
|
||||
}
|
||||
}
|
||||
const finalPrevHeight = Math.max(0, prev - padTop - topMargin - rowGap - (listStartIndex > 0 ? rowGap : 0));
|
||||
const finalPostHeight = post > 0 ? Math.max(0, post - 2 * rowGap) : 0;
|
||||
return { prevHeight: finalPrevHeight, postHeight: finalPostHeight, renderedList, listStartIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.VirtualScroll = VirtualScroll;
|
||||
1
utilities/VirtualScroll.min.js
vendored
Normal file
1
utilities/VirtualScroll.min.js
vendored
Normal file
@ -0,0 +1 @@
|
||||
const VirtualScroll=(t={})=>{const e=new Map,l=new Map;let i=1;const n=globalThis.Util.newAvg();let o=0,a=0,r=0,s=null,c=null,h=!1;const g=t.itemHeight||null;return{reset:(t,u)=>{if(h=!1,e.clear(),l.clear(),n.clear(),r=0,s=null,c=null,!t?.length)return[];const f=t.length;i=Math.ceil(Math.sqrt(f))||10;const m=window.getComputedStyle(u);o=parseFloat(m.paddingTop)||0,a=parseFloat(m.rowGap)||0;const p=Math.max(10,Math.ceil((u.clientHeight||100)/(g||32)));return t.slice(0,Math.min(3*p,f))},init:(t,o)=>{if(h)return;const a=t.length;let r=g||n.get()||32;a>0&&"object"==typeof t[0]&&null!==t[0]&&t[0]._itemHeight&&(r=t[0]._itemHeight),n.add(r),null===s&&(s=0,c=0);for(let l=0;l<a;l++)if(!e.has(l)){const i="object"==typeof t[l]&&null!==t[l]&&t[l]._itemHeight?t[l]._itemHeight:r;e.set(l,i)}for(let t=0;t<a;t+=i)l.set(t,Math.min(i,a-t)*r);h=!0,o()},update:(t,o)=>{if(0===o.offsetHeight)return;if(null===s){const t=window.getComputedStyle(o);s=parseFloat(t.marginTop)||0,c=parseFloat(t.marginBottom)||0}0!==t||r||(r=s);const h=o.offsetHeight+s+c+a,g=e.get(t);if(h!==g){e.set(t,h),n.add(h);const o=h-(g||0),a=t-t%i;l.has(a)&&l.set(a,l.get(a)+o)}},calc:(t,s)=>{if(!h||!s)return null;const c=s.length,g=Math.max(16,n.get()||32);let u=Math.max(10,Math.ceil((t.clientHeight||100)/g)),f=o+r+a,m=0,p=0,M=0,d=0,H=[];const w=t.scrollTop;let x=0;for(let t=0;t<c;t++){if(++x>2*c)throw new Error("VirtualScroll infinite loop");if(0===p){const n=l.get(t);if(n&&f+n<=w&&t+i<c)f+=n,t+=i-1;else{const l=e.get(t);if(f+l<=w&&t<c-1)f+=l;else{p=1;let l=Math.max(0,t);M=Math.max(0,l-u),d=Math.min(M+3*u,c),t=d-1,H=s.slice(M,d);for(let t=M;t<l;t++)f-=e.get(t)}}}else if(1===p){const n=l.get(t);n?(m+=n,t+=i-1):m+=e.get(t)}}return{prevHeight:Math.max(0,f-o-r-a-(M>0?a:0)),postHeight:m>0?Math.max(0,m-2*a):0,renderedList:H,listStartIndex:M}}}};globalThis.VirtualScroll=VirtualScroll;
|
||||
24
utilities/VirtualScroll.yaml
Normal file
24
utilities/VirtualScroll.yaml
Normal file
@ -0,0 +1,24 @@
|
||||
name: VirtualScroll
|
||||
|
||||
purpose: Variable-height virtual scrolling utility used by List and DataTable.
|
||||
|
||||
factory:
|
||||
signature: VirtualScroll(options)
|
||||
options:
|
||||
itemHeight: Optional fixed initial item height.
|
||||
|
||||
methods:
|
||||
reset: Initialize a new list and return its first render window.
|
||||
init: Measure initial data and invoke refresh callback.
|
||||
update: Measure one rendered item.
|
||||
calc: Return paddings, renderedList, and listStartIndex for current scroll position.
|
||||
|
||||
rules:
|
||||
- Treat this as an internal UI utility unless implementing a virtualized component.
|
||||
- Call update after each rendered row can be measured.
|
||||
|
||||
example: |
|
||||
const virtual = VirtualScroll({ itemHeight: 40 })
|
||||
virtual.reset(rows, container)
|
||||
virtual.init(rows, () => refresh())
|
||||
const view = virtual.calc(container, rows)
|
||||
Loading…
x
Reference in New Issue
Block a user