refactor(ui): 整理组件目录并接入DataGrid(by AI)

This commit is contained in:
Star 2026-07-12 13:00:02 +08:00
parent e78df56494
commit e60e5155c4
54 changed files with 1166 additions and 187 deletions

25
AI.yaml
View File

@ -11,13 +11,14 @@ rules:
- 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. - 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. - 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. - 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`). - Public source and YAML use the same PascalCase basename (for example `components/base/List.js` and `components/base/List.yaml`, `frameworks/State.js` and `frameworks/State.yaml`).
loading: loading:
example: '<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="List,AutoForm"></script>' example: '<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="base,form"></script>'
rules: rules:
- Load ui.js in head without async, defer, or type="module". - Load ui.js in head without async, defer, or type="module".
- Always write components with every public component used by the page. - Always write components with every public component used by the page.
- Use `base` or `form` to load the documented component category; individual names and category names can be mixed.
- Do not list utilities or framework dependencies. - Do not list utilities or framework dependencies.
- ui.js loads frameworks/State.js and frameworks/Bootstrap.js beside itself. - 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. - npm run build writes source modification versions into ui.js; ui.min.js loads matching adjacent .min.js files.
@ -27,20 +28,20 @@ framework:
- frameworks/Bootstrap.yaml - frameworks/Bootstrap.yaml
components: components:
- components/API.yaml - components/base/API.yaml
- components/AutoForm.yaml - components/base/AutoForm.yaml
- components/List.yaml - components/base/List.yaml
- components/Modal.yaml - components/base/Modal.yaml
- components/Dialog.yaml - components/base/Dialog.yaml
- components/Toast.yaml - components/base/Toast.yaml
- components/Nav.yaml - components/base/Nav.yaml
- components/Resizer.yaml - components/base/Resizer.yaml
- components/form/DatePicker.yaml - components/form/DatePicker.yaml
- components/form/ColorPicker.yaml - components/form/ColorPicker.yaml
- components/form/IconPicker.yaml - components/form/IconPicker.yaml
- components/form/TagsInput.yaml - components/form/TagsInput.yaml
- components/DataTable.yaml - components/data/DataGrid.yaml
- components/DataChart.yaml - components/data/Chart.yaml
utilities: utilities:
- utilities/HTTP.yaml - utilities/HTTP.yaml

View File

@ -7,10 +7,10 @@
Pages load `ui.js` directly from a CDN and explicitly declare required public APIs: Pages load `ui.js` directly from a CDN and explicitly declare required public APIs:
```html ```html
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="API,AutoForm,List,DataTable"></script> <script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="base,form"></script>
``` ```
`ui.js` always loads every file in `frameworks/` and `utilities/`. The `components` attribute only lists public components used by the page. `ui.js` always loads every file in `frameworks/` and `utilities/`. The `components` attribute accepts public component names and the `base` and `form` categories; both forms can be mixed.
Load `ui.js` in `<head>` as a normal script. Do not use `async`, `defer`, or `type="module"`: it writes the requested classic scripts into the parser stream so state can scan the complete DOM at `DOMContentLoaded` before first render. Load `ui.js` in `<head>` as a normal script. Do not use `async`, `defer`, or `type="module"`: it writes the requested classic scripts into the parser stream so state can scan the complete DOM at `DOMContentLoaded` before first render.

View File

@ -5,7 +5,7 @@
`@apigo.cc/ui` 是不需要生产构建的原生 JavaScript UI 组件库。页面使用一个 `ui.js` 入口,并且每个页面都显式写出使用的组件: `@apigo.cc/ui` 是不需要生产构建的原生 JavaScript UI 组件库。页面使用一个 `ui.js` 入口,并且每个页面都显式写出使用的组件:
```html ```html
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="API,AutoForm,List,DataTable"></script> <script src="https://cdn.jsdelivr.net/npm/@apigo.cc/ui@1.0.0/ui.js" components="base,form"></script>
``` ```
`ui.js` 默认加载 `frameworks/` 和底层 `utilities/``components` 中只填写页面直接使用的公开组件名,例如 `components="List,AutoForm,Resizer"`;组件依赖会自动加载,不需要填写 state、bootstrap、HTTP、VirtualScroll 或 MouseMover。 `ui.js` 默认加载 `frameworks/` 和底层 `utilities/``components` 中只填写页面直接使用的公开组件名,例如 `components="List,AutoForm,Resizer"`;组件依赖会自动加载,不需要填写 state、bootstrap、HTTP、VirtualScroll 或 MouseMover。

View File

@ -1,76 +0,0 @@
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

View File

@ -1 +0,0 @@
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)))}};

View File

@ -1 +0,0 @@
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'));

View File

@ -1,31 +0,0 @@
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

View File

@ -1,11 +0,0 @@
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' }
]

View File

@ -1,15 +0,0 @@
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>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>components.API tests</title> <title>components.API tests</title>
<script src="../ui.js" components="API"></script> <script src="../../ui.js" components="API"></script>
<script> <script>
const successRequest = { url: '/__http__/json', method: 'GET' } const successRequest = { url: '/__http__/json', method: 'GET' }
const errorRequest = { url: '/__http__/api-error', method: 'GET' } const errorRequest = { url: '/__http__/api-error', method: 'GET' }

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>components.form.AutoForm tests</title> <title>components.form.AutoForm tests</title>
<script src="../ui.js" components="API,AutoForm,DatePicker,ColorPicker,IconPicker,TagsInput"></script> <script src="../../ui.js" components="API,AutoForm,DatePicker,ColorPicker,IconPicker,TagsInput"></script>
<script> <script>
const profile = { const profile = {
name: 'Ada', password: 'secret', email: 'ada@example.test', age: 36, name: 'Ada', password: 'secret', email: 'ada@example.test', age: 36,

View File

@ -13,13 +13,19 @@ globalThis.Component.register('Dialog', globalThis.Component.getSetupFunction('M
</div> </div>
<div class="modal-footer py-2 px-3 bg-light border-0 d-flex justify-content-center gap-3"> <div class="modal-footer py-2 px-3 bg-light border-0 d-flex justify-content-center gap-3">
<template $each="this.state?.buttons"> <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> <button type="button" $class="btn btn-\${this.state?.type} px-4" $onclick="this.result=index+1; this.hide()" $text="item"></button>
</template> </template>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
`)) `))
,
globalThis.Util.makeDom(/*html*/`
<style>
Dialog.modal { background: transparent; border: 0; padding: 0; }
</style>
`)
let _dialogCount = 0 let _dialogCount = 0
globalThis.Dialog = { globalThis.Dialog = {

1
components/base/Dialog.min.js vendored Normal file
View 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" $onclick="this.result=index+1; this.hide()" $text="item"></button>\n </template>\n </div>\n </div>\n </div>\n</div>\n')),globalThis.Util.makeDom("\n<style>\n Dialog.modal { background: transparent; border: 0; padding: 0; }\n</style>\n");let _dialogCount=0;globalThis.Dialog={show({title:t="",message:e="",buttons:s=["{#Close#}"],type:a="body"}){const n=document.body.appendChild(document.createElement("Dialog"));return n.style.zIndex=2e3+ ++_dialogCount,Promise.resolve().then(()=>{Object.assign(n.state,{message:e,title:t,type:a,buttons:s}),n.show()}),new Promise(t=>{n.addEventListener("change",e=>{_dialogCount--,t(n.result||0),n.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)))}};

View File

@ -0,0 +1,16 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
<title>components.Dialog tests</title><script src="../../ui.js" components="Dialog"></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.Dialog</h1><span id="status" class="badge text-bg-secondary">未测试</span><button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button><span id="coverage" class="small text-body-secondary"></span></header><main class="flex-fill overflow-auto"></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 = ['Dialog.show', 'Dialog.alert', 'Dialog.confirm', 'title', 'message', 'buttons', 'type']; const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event))); const tick = () => new Promise(resolve => setTimeout(resolve, 500)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
window.runTest = async () => { errors.length = 0; const r = { name: 'components.Dialog', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }
const shown = Dialog.show({ title: 'Delete', message: 'Remove user?', buttons: ['No', 'Yes'], type: 'danger' }); await tick(); let dialog = document.querySelector('Dialog'); assert(r, 'title', dialog.querySelector('.modal-header')?.textContent === 'Delete', 'title renders dialog title'); assert(r, 'message', dialog.querySelector('.modal-body')?.textContent.includes('Remove user?'), 'message renders dialog body text'); assert(r, 'buttons', [...dialog.querySelectorAll('.modal-footer button')].map(button => button.textContent).join(',') === 'No,Yes', 'buttons render supplied labels'); assert(r, 'type', dialog.querySelector('.modal-content')?.classList.contains('border-danger'), 'type applies the Bootstrap contextual type'); dialog.querySelectorAll('.modal-footer button')[1].click(); assert(r, 'Dialog.show', await shown === 2, 'show resolves with the selected 1-based button index')
const alert = Dialog.alert('Saved'); await tick(); dialog = document.querySelector('Dialog'); dialog.querySelector('.modal-footer button').click(); assert(r, 'Dialog.alert', await alert === 1, 'alert shows its message and resolves after closing')
const confirmYes = Dialog.confirm('Delete?'); await tick(); document.querySelector('Dialog').querySelectorAll('.modal-footer button')[1].click(); const confirmNo = Dialog.confirm('Keep?'); await tick(); document.querySelector('Dialog').querySelectorAll('.modal-footer button')[0].click(); assert(r, 'Dialog.confirm', await confirmYes === true && await confirmNo === false, 'confirm resolves to boolean for Confirm and Cancel')
r.coverage.tested = [...cases]; r.coverage.percent = 100; r.consoleErrors = [...errors]; r.status = r.failed || r.consoleErrors.length ? 'failed' : 'passed'; document.querySelector('#status').className = 'badge text-bg-' + (r.status === 'passed' ? 'success' : 'danger'); document.querySelector('#status').textContent = r.status === 'passed' ? '通过' : '失败'; document.querySelector('#coverage').textContent = '覆盖 100%'; document.querySelector('#result').textContent = JSON.stringify(r, null, 2); return r }; document.querySelector('#runAll').onclick = window.runTest
</script></body></html>

View File

@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>components.List tests</title> <title>components.List tests</title>
<script src="../ui.js" components="List"></script> <script src="../../ui.js" components="List"></script>
<script> <script>
const users = Array.from({ length: 60 }, (_, index) => ({ id: index + 1, label: 'User ' + (index + 1), summary: index % 2 ? 'Editor' : 'Admin' })) 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 groups = [{ id: 'ops', label: 'Operations', summary: '20 members' }, { id: 'design', label: 'Design', summary: '20 members' }, { id: 'qa', label: 'QA', summary: '20 members' }]

View File

@ -10,7 +10,9 @@ globalThis.Component.register('Modal', container => {
document.activeElement?.blur() document.activeElement?.blur()
container.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: false })) container.dispatchEvent(new CustomEvent('change', { bubbles: false, detail: false }))
}) })
globalThis.Util.copyFunction(container, container.modal, 'show', 'hide') // Dialog is a native HTML element whose show/hide methods would otherwise shadow Bootstrap's methods.
container.show = (...args) => container.modal.show(...args)
container.hide = (...args) => container.modal.hide(...args)
}, globalThis.Util.makeDom(/*html*/` }, globalThis.Util.makeDom(/*html*/`
<div class="modal fade" data-bs-backdrop="static"> <div class="modal fade" data-bs-backdrop="static">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">

1
components/base/Modal.min.js vendored Normal file
View 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}))}),t.show=(...d)=>t.modal.show(...d),t.hide=(...d)=>t.modal.hide(...d)},globalThis.Util.makeDom('\n<div class="modal fade" data-bs-backdrop="static">\n <div class="modal-dialog modal-dialog-centered">\n <div $class="modal-content border-${this.state?.type || \'primary\'} border-2 shadow-lg">\n <div slot-id="header" class="modal-header py-2 px-3 bg-light">\n <h6 $class="modal-title fw-bold text-${this.state?.type || \'primary\'}" $text="this.state?.title"></h6>\n <button type="button" class="btn btn-link ms-2 bi bi-x-lg link-reset p-0" style="color:inherit; text-decoration:none" data-bs-dismiss="modal"></button>\n </div>\n <div slot-id="body" class="modal-body p-3"></div>\n <div slot-id="footer" class="modal-footer py-2 px-3 bg-light"></div>\n </div>\n </div>\n</div>\n'));

View File

@ -0,0 +1,37 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>components.Modal tests</title>
<script src="../../ui.js" components="Modal"></script>
<script>
const modalModel = { open: false }
const modalState = { title: 'Edit user', type: 'success' }
</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.Modal</h1><span id="status" class="badge text-bg-secondary">未测试</span><button id="runAll" class="btn btn-primary btn-sm ms-auto" type="button">测试全部</button><span id="coverage" class="small text-body-secondary"></span></header>
<main class="flex-fill overflow-auto"><Modal id="titleModal" $.state.title="modalState.title" $.state.type="modalState.type"></Modal><Modal id="editModal" $bind="modalModel.open" $.state.type="modalState.type"><div slot="header" id="customHeader">Custom header</div><div slot="body" id="customBody">Custom body</div><div slot="footer" id="customFooter">Custom footer</div></Modal></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 = ['binding', 'title', 'type', 'show', 'hide', 'change-event', 'header-slot', 'body-slot', 'footer-slot']
const errors = []; const originalError = console.error; console.error = (...args) => { errors.push(args.map(String).join(' ')); originalError(...args) }; addEventListener('error', event => errors.push(String(event.message || event.error || event))); addEventListener('unhandledrejection', event => errors.push(String(event.reason || event)))
const tick = () => new Promise(resolve => setTimeout(resolve, 500)); const assert = (r, feature, ok, message) => { r.assertions++; if (ok) r.passed++; else { r.failed++; r.failures.push({ feature, message }) } }
window.runTest = async () => { errors.length = 0; const r = { name: 'components.Modal', status: 'running', assertions: 0, passed: 0, failed: 0, failures: [], consoleErrors: [], coverage: { tested: [], total: cases, percent: 0 } }; const modal = document.querySelector('#editModal'); let change
modal.addEventListener('change', event => { change = event.detail }, { once: true }); modal.dispatchEvent(new CustomEvent('bind', { detail: true })); await tick()
assert(r, 'binding', modal.classList.contains('show'), '$bind true shows the modal')
assert(r, 'title', document.querySelector('#titleModal .modal-title')?.textContent === 'Edit user', 'title renders the default header title')
assert(r, 'type', modal.querySelector('.modal-content')?.classList.contains('border-success'), 'type applies the Bootstrap contextual type')
assert(r, 'show', typeof modal.show === 'function' && modal.classList.contains('show'), 'show() is exposed and shows the Bootstrap modal')
assert(r, 'header-slot', modal.querySelector('#customHeader')?.textContent === 'Custom header', 'header slot replaces the header')
assert(r, 'body-slot', modal.querySelector('#customBody')?.textContent === 'Custom body', 'body slot replaces the body')
assert(r, 'footer-slot', modal.querySelector('#customFooter')?.textContent === 'Custom footer', 'footer slot replaces the footer')
modal.hide(); await tick(); assert(r, 'hide', !modal.classList.contains('show'), 'hide() hides the Bootstrap modal'); assert(r, 'change-event', change === false, 'change detail is false when the modal is hidden')
r.coverage.tested = [...cases]; r.coverage.percent = 100; r.consoleErrors = [...errors]; r.status = r.failed || r.consoleErrors.length ? 'failed' : 'passed'; document.querySelector('#status').className = 'badge text-bg-' + (r.status === 'passed' ? 'success' : 'danger'); document.querySelector('#status').textContent = r.status === 'passed' ? '通过' : '失败'; document.querySelector('#coverage').textContent = '覆盖 100%'; document.querySelector('#result').textContent = JSON.stringify(r, null, 2); return r }
document.querySelector('#runAll').onclick = window.runTest
</script>
</body>
</html>

View File

@ -61,12 +61,12 @@ globalThis.Component.register('Nav', container => {
</button> </button>
<template $if="this.state.openName===item.name"> <template $if="this.state.openName===item.name">
<div class="d-flex flex-column w-100 ps-2 mt-1"> <div class="d-flex flex-column w-100 ps-2 mt-1">
<template $each="item.list" as="subitem"> <template $each="item.items || item.list" as="subitem">
<template $if="subitem.type==='label'"> <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> <div $class="small text-uppercase text-body-secondary fw-semibold px-2 py-1 \${subitem.class || ''}" $text="subitem.label"></div>
</template> </template>
<template $if="subitem.type==='button'"> <template $if="subitem.type==='button'">
<button $class="nav-link w-100 text-start py-1 px-2 \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)"> <button $class="nav-link w-100 text-start py-1 px-2 \${this.state.value===subitem.name?'active':''} \${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> <i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
</button> </button>
</template> </template>
@ -87,12 +87,12 @@ globalThis.Component.register('Nav', container => {
<i $class="bi bi-\${item.icon} me-2"></i><span $class="'d-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'" $text="item.label"></span> <i $class="bi bi-\${item.icon} me-2"></i><span $class="'d-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'" $text="item.label"></span>
</button> </button>
<div class="dropdown-menu dropdown-menu-end p-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px;'"> <div class="dropdown-menu dropdown-menu-end p-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px;'">
<template $each="item.list" as="subitem"> <template $each="item.items || item.list" as="subitem">
<template $if="subitem.type==='label'"> <template $if="subitem.type==='label'">
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div> <div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>
</template> </template>
<template $if="subitem.type==='button'"> <template $if="subitem.type==='button'">
<button $class="dropdown-item \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)"> <button $class="dropdown-item \${this.state.value===subitem.name?'active':''} \${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> <i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
</button> </button>
</template> </template>

File diff suppressed because one or more lines are too long

View File

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

45
components/base/Nav.yaml Normal file
View File

@ -0,0 +1,45 @@
name: Nav
attributes:
vertical:
type: boolean
state:
brand: "{ image?, icon?, label? }"
list: Array of navigation item objects; bind with `$.state.list="navItems"`.
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?, bind?, class?, width? }"
example: |
<script>
const navBrand = { image: '/res/apiGo.min.svg' }
const navItems = [
{ type: 'button', name: 'home', label: 'Home', icon: 'house' },
{ type: 'dropdown', name: 'admin', label: 'Admin', icon: 'shield', items: [
{ type: 'label', label: 'Manage' },
{ type: 'button', name: 'users', label: 'Users', icon: 'people' },
{ type: 'switch', name: 'alerts', label: 'Alerts', bind: { alerts: true } }
] },
{ type: 'fill' },
{ type: 'button', name: 'settings', label: 'Settings', icon: 'gear' }
]
</script>
<Nav class="border-end" $.state.brand="navBrand" $.state.list="navItems" $bind="Hash.nav" vertical></Nav>
rules:
- Bind `$bind="Hash.nav"` when the selected navigation item must survive a page refresh.
- Use the same `navItems` data for horizontal and vertical navigation when comparing layouts.

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>components.Resizer tests</title> <title>components.Resizer tests</title>
<script src="../ui.js" components="Resizer"></script> <script src="../../ui.js" components="Resizer"></script>
</head> </head>
<body> <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 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>

View File

@ -3,7 +3,7 @@
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>components.Toast tests</title> <title>components.Toast tests</title>
<script src="../ui.js" components="Toast"></script> <script src="../../ui.js" components="Toast"></script>
</head> </head>
<body> <body>
<div toast-container="custom"></div> <div toast-container="custom"></div>

View File

@ -1,7 +1,7 @@
name: DataChart name: Chart
constructor: constructor:
signature: new DataChart(canvas, options) signature: new Chart(canvas, options)
options: options:
type: line, bar, or pie. Default is line. type: line, bar, or pie. Default is line.
data: Chart.js data object or source object array. data: Chart.js data object or source object array.
@ -16,19 +16,19 @@ component:
options: Chart.js options. options: Chart.js options.
map: "{ labels, values, label }" map: "{ labels, values, label }"
property: property:
chartInstance: Underlying DataChart instance. chartInstance: Underlying Chart instance.
methods: methods:
update: Update optional data and redraw. update: Update optional data and redraw.
destroy: Destroy the underlying Chart.js instance. destroy: Destroy the underlying Chart.js instance.
rules: rules:
- Give the DataChart container an explicit height. - Give the Chart container an explicit height.
- Use map only when data is an array of objects. - Use map only when data is an array of objects.
- DataChart destroys its Chart.js instance when the component unloads. - Chart destroys its Chart.js instance when the component unloads.
example: | example: |
<DataChart id="sales" type="bar"></DataChart> <Chart id="sales" type="bar"></Chart>
<script> <script>
const chart = document.querySelector('#sales') const chart = document.querySelector('#sales')
chart.state.data = [ chart.state.data = [

778
components/data/DataGrid.js Normal file
View File

@ -0,0 +1,778 @@
/**
* DataGrid Component Module
* Consolidated with perf, scroll, and selection managers.
*/
// Global Configuration
const MODE_MAP = {
text: ['contains', 'equals', 'starts', 'ends'],
textarea: ['contains', 'equals', 'starts', 'ends'],
number: ['=', '>', '<', 'between'],
date: ['=', '>', '<', 'between'],
select: ['contains', 'equals'],
TagsInput: ['contains', 'equals', 'starts', 'ends']
};
const MODE_ICONS = {
'contains': 'bi-search', 'equals': 'bi-distribute-vertical', 'starts': 'bi-align-start', 'ends': 'bi-align-end',
'=': 'bi-calculator', '>': 'bi-chevron-right', '<': 'bi-chevron-left', 'between': 'bi-arrows-expand'
};
const DataGridConfig = {
_fieldTypes: new Map(),
registerFieldType: (config) => {
DataGridConfig._fieldTypes.set(config.value, config);
},
getFieldTypes: () => Array.from(DataGridConfig._fieldTypes.values())
};
// Register Built-in Types
DataGridConfig.registerFieldType({
value: 'text', label: '{#Text#}', typeForDB: 'v4096',
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="text"' }]
});
DataGridConfig.registerFieldType({
value: 'number', label: '{#Number#}', typeForDB: 'ff',
schema: [
{ name: 'decimals', label: 'Decimals', type: 'number', setting: { min: 0, max: 10 }, if: 'this.data.user_type=="number"' },
{ name: 'prefix', label: 'Prefix (e.g. $)', type: 'text', if: 'this.data.user_type=="number"' },
{ name: 'suffix', label: 'Suffix (e.g. %)', type: 'text', if: 'this.data.user_type=="number"' },
{ name: 'thousandSep', label: 'Thousand Sep', type: 'switch', if: 'this.data.user_type=="number"' }
],
formatter: (val, field) => {
if (val == null || val === '') return '';
let num = Number(val);
if (isNaN(num)) return val;
const s = field.settings || {};
if (s.decimals !== undefined) num = num.toFixed(s.decimals);
let str = String(num);
if (s.thousandSep) {
const parts = str.split('.');
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
str = parts.join('.');
}
return (s.prefix || '') + str + (s.suffix || '');
}
});
DataGridConfig.registerFieldType({
value: 'select', label: '{#Single Select#}', typeForDB: 'v1024',
schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="select"' }],
formatter: (val, field) => {
if (val == null || val === '') return '';
const opts = field.settings?.options || [];
const opt = opts.find(o => typeof o === 'object' ? o.value == val : o == val);
return opt ? (typeof opt === 'object' ? opt.label : opt) : val;
}
});
DataGridConfig.registerFieldType({
value: 'checkbox', label: '{#Multi Select#}', typeForDB: 'v4096',
schema: [{ name: 'options_str', label: 'Options', type: 'textarea', setting: { rows: 3, placeholder: 'Label:Value per line' }, if: 'this.data.user_type=="checkbox"' }],
formatter: (val, field) => {
if (!Array.isArray(val)) return val == null ? '' : String(val);
const opts = field.settings?.options || [];
return val.map(v => {
const opt = opts.find(o => typeof o === 'object' ? o.value == v : o == v);
return opt ? (typeof opt === 'object' ? opt.label : opt) : v;
}).join(', ');
}
});
DataGridConfig.registerFieldType({
value: 'switch', label: '{#Switch#}', typeForDB: 'b',
schema: [
{ name: 'labelOn', label: 'Label On', type: 'text', if: 'this.data.user_type=="switch"' },
{ name: 'labelOff', label: 'Label Off', type: 'text', if: 'this.data.user_type=="switch"' }
],
formatter: (val, field) => {
const s = field.settings || {};
return val ? (s.labelOn || 'Yes') : (s.labelOff || 'No');
}
});
DataGridConfig.registerFieldType({
value: 'datetime', label: '{#DateTime#}', typeForDB: 'dt',
schema: [{ name: 'format', label: 'Format', type: 'text', setting: { placeholder: 'YYYY-MM-DD' }, if: 'this.data.user_type=="datetime"' }]
});
DataGridConfig.registerFieldType({
value: 'textarea', label: '{#Long Text#}', typeForDB: 't',
schema: [{ name: 'placeholder', label: 'Placeholder', type: 'text', if: 'this.data.user_type=="textarea"' }]
});
// Managers Factory Functions (Internalized)
const createPerfMonitor = () => {
let enabled = !!globalThis.__DT_PERF_MODE__;
const stats = { refreshTime: 0, refreshCount: 0, scrollCount: 0, totalNodes: 0 };
if (enabled && !globalThis.__statePerformanceTelemetry) {
globalThis.__statePerformanceTelemetry = { scanCount: 0, reuseCount: 0, moveCount: 0 };
}
return {
get stats() { return stats; },
enable: () => { enabled = true; },
disable: () => { enabled = false; },
onScroll: () => { if (enabled) stats.scrollCount++; },
startFrame: () => {
if (!enabled) return null;
return {
start: performance.now(),
scan: globalThis.__statePerformanceTelemetry?.scanCount || 0,
move: globalThis.__statePerformanceTelemetry?.moveCount || 0,
reuse: globalThis.__statePerformanceTelemetry?.reuseCount || 0
};
},
endFrame: (startData, renderedCount) => {
if (!enabled || !startData) return;
stats.refreshCount++; stats.totalNodes += renderedCount;
const elapsed = performance.now() - startData.start;
stats.refreshTime += elapsed;
const stPerf = globalThis.__statePerformanceTelemetry;
if (stPerf) {
const scans = stPerf.scanCount - startData.scan;
const moves = stPerf.moveCount - startData.move;
const reuses = stPerf.reuseCount - startData.reuse;
if (scans > 0 || elapsed > 2) {
console.log(`[DataGrid Frame] Time: ${elapsed.toFixed(2)}ms, Scans: ${scans}, Moves: ${moves}, Reuses: ${reuses}, Rows: ${renderedCount}`);
}
}
}
};
};
const createScrollManager = (container, state, onRenderedListChange) => {
const vs = globalThis.VirtualScroll({ itemHeight: 40 });
let scrollEl = null;
const refresh = (isLayoutChange = false) => {
if (!scrollEl) return;
const res = vs.calc(scrollEl, state.list);
if (res) {
if (!isLayoutChange && state.prevHeight === res.prevHeight && state.postHeight === res.postHeight && state._listStartIndex === res.listStartIndex && state._renderedList.length === res.renderedList.length) return;
Object.assign(state, { prevHeight: res.prevHeight, postHeight: res.postHeight, _listStartIndex: res.listStartIndex, _renderedList: res.renderedList });
onRenderedListChange?.(res.renderedList.length, isLayoutChange);
}
};
return {
init: () => { scrollEl = container.querySelector('.dt-main'); },
reset: (list) => { state._listStartIndex = 0; vs.reset(list, scrollEl || container); if (state.list === list) vs.init(list, () => refresh(true)); },
refresh, onScroll: () => refresh(false)
};
};
const createSelectionManager = (container, state) => {
let activeBounds = null; let startCell = null; let multiSelections = [];
const isCellSelected = (r, c) => {
if (activeBounds && r >= activeBounds.minRow && r <= activeBounds.maxRow && c >= activeBounds.minCol && c <= activeBounds.maxCol) return true;
return multiSelections.some(s => r >= s.minRow && r <= s.maxRow && c >= s.minCol && c <= s.maxCol);
};
let lastHadSelection = false;
const applySelectionUI = () => {
if (globalThis.__DT_FEATURES__ && !globalThis.__DT_FEATURES__.selection) return;
let boundMinRow = Infinity, boundMaxRow = -Infinity;
if (activeBounds) { boundMinRow = Math.min(boundMinRow, activeBounds.minRow); boundMaxRow = Math.max(boundMaxRow, activeBounds.maxRow); }
multiSelections.forEach(s => { boundMinRow = Math.min(boundMinRow, s.minRow); boundMaxRow = Math.max(boundMaxRow, s.maxRow); });
const hasSelection = boundMinRow !== Infinity;
if (!hasSelection && !lastHadSelection) return;
lastHadSelection = hasSelection;
const body = container.querySelector('.dt-body');
if (!body) return;
const rowNodes = body.querySelectorAll('.dt-body-row');
rowNodes.forEach(rowNode => {
const absoluteRow = (rowNode._ref?.rIdx ?? -1) + state._listStartIndex;
const cells = rowNode.querySelectorAll('.dt-cell');
if (!hasSelection || absoluteRow < boundMinRow || absoluteRow > boundMaxRow) { cells.forEach(cell => cell.classList.remove('dt-cell-selected')); return; }
cells.forEach((cell, cIdx) => { if (isCellSelected(absoluteRow, cIdx)) cell.classList.add('dt-cell-selected'); else cell.classList.remove('dt-cell-selected'); });
});
};
const updateStatus = () => {
let count = 0; if (activeBounds) count += (activeBounds.maxRow - activeBounds.minRow + 1);
multiSelections.forEach(s => count += (s.maxRow - s.minRow + 1));
state.selectedRowCount = count;
};
const clearAllActive = (keepSelection = false) => { if (!keepSelection) { activeBounds = null; startCell = null; multiSelections = []; applySelectionUI(); updateStatus(); } };
const startSelect = (row, col, e) => {
const alreadySelected = isCellSelected(row, col);
const isRange = (activeBounds && (activeBounds.minRow !== activeBounds.maxRow || activeBounds.minCol !== activeBounds.maxCol)) || multiSelections.length > 0;
if (e.shiftKey && startCell) { activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) }; }
else {
if (alreadySelected && !e.ctrlKey && !e.metaKey) { if (!isRange) container._potentialCancel = { row, col }; }
else { if (!e.ctrlKey && !e.metaKey) clearAllActive(); else if (activeBounds && !alreadySelected) multiSelections.push(activeBounds); startCell = { row, col }; activeBounds = { minRow: row, maxRow: row, minCol: col, maxCol: col }; }
state.isSelecting = true;
}
applySelectionUI(); updateStatus(); container.focus();
};
const updateSelect = (row, col) => { if (state.isSelecting && startCell) { activeBounds = { minRow: Math.min(startCell.row, row), maxRow: Math.max(startCell.row, row), minCol: Math.min(startCell.col, col), maxCol: Math.max(startCell.col, col) }; container._potentialCancel = null; applySelectionUI(); updateStatus(); } };
const endSelect = () => { if (container._potentialCancel) { const { row, col } = container._potentialCancel; if (isCellSelected(row, col)) clearAllActive(); container._potentialCancel = null; } state.isSelecting = false; };
const getSelectionBounds = () => {
if (!activeBounds) return null;
let minRow = activeBounds.minRow, maxRow = activeBounds.maxRow, minCol = activeBounds.minCol, maxCol = activeBounds.maxCol;
multiSelections.forEach(s => { minRow = Math.min(minRow, s.minRow); maxRow = Math.max(maxRow, s.maxRow); minCol = Math.min(minCol, s.minCol); maxCol = Math.max(maxCol, s.maxCol); });
return { minRow, maxRow, minCol, maxCol };
};
const copy = async () => {
const bounds = getSelectionBounds(); if (!bounds) return;
const text = state.list.slice(bounds.minRow, bounds.maxRow + 1).map(row => {
return state.fields.slice(bounds.minCol, bounds.maxCol + 1).map(f => {
let val = String(row[f.id] ?? '');
if (val.includes('\t') || val.includes('\n') || val.includes('"')) val = '"' + val.replace(/"/g, '""') + '"';
return val;
}).join('\t');
}).join('\n');
await navigator.clipboard.writeText(text);
};
const paste = async () => {
try {
const text = await navigator.clipboard.readText(); if (!text) return;
const bounds = getSelectionBounds(); if (!bounds) return;
const rows = text.split(/\r?\n/).filter(line => line.length > 0).map(line => {
const cells = []; let current = '', inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') { if (inQuotes && line[i+1] === '"') { current += '"'; i++; } else inQuotes = !inQuotes; }
else if (char === '\t' && !inQuotes) { cells.push(current); current = ''; }
else current += char;
}
cells.push(current); return cells;
});
let { minRow: startRow, minCol: startCol, maxRow, maxCol } = bounds;
if (minRow === maxRow && minCol === maxCol) {
maxRow = Infinity;
maxCol = Infinity;
}
const body = container.querySelector('.dt-body');
const rowNodes = body ? Array.from(body.childNodes).filter(n => n.classList?.contains('dt-body-row')) : [];
let anyRowChanged = false;
rows.forEach((rowData, rOffset) => {
const rIdx = startRow + rOffset; if (rIdx > maxRow || rIdx >= state.list.length) return;
const rowItem = state.list[rIdx]; let rowChanged = false;
rowData.forEach((cellData, cOffset) => { const cIdx = startCol + cOffset; if (cIdx > maxCol || cIdx >= state.fields.length) return; const field = state.fields[cIdx]; rowItem[field.id] = cellData; rowChanged = true; });
if (rowChanged) anyRowChanged = true;
});
if (anyRowChanged) state.list = [...state.list];
} catch (err) { console.error('Paste Error:', err); }
};
return { applySelectionUI, clearAllActive, startSelect, updateSelect, endSelect, getSelectionBounds, copy, paste };
};
// Component Registration
globalThis.Component.register('DataGrid', container => {
if (!container.state) container.state = globalThis.NewState({})
const state = container.state
container.editable = container.hasAttribute('editable')
Object.assign(state, {
list: [], fields: [], _renderedList: [],
prevHeight: 0, postHeight: 0, _listStartIndex: 0,
selectedRowCount: 0,
_originalList: [],
sortConfig: { fieldId: null, direction: null },
filterConfig: {},
activeFieldId: null, activeField: null, activeModes: [],
_columnStats: {}, _internalUpdate: false, _appliedHash: '', _fieldsDirty: false, _masterCellNodes: null,
isDirty: false, isBulkEdit: null,
editable: container.editable
})
const perf = createPerfMonitor();
state.perf = perf.stats;
const selection = createSelectionManager(container, state);
const scroll = createScrollManager(container, state, () => setTimeout(() => selection.applySelectionUI(), 50));
const menuNode = container.querySelector('.dt-column-menu');
if (menuNode) menuNode._thisObj = container;
container.onColumnResizing = (field, e) => container.style.setProperty(`--w-${field.id}`, e.detail.newSize + 'px');
container.onColumnResize = (field, e) => {
const idx = state.fields.findIndex(f => f.id === field.id);
if (idx !== -1) { state.fields[idx].width = e.detail.newSize; state.fields = [...state.fields]; }
};
let _editorOverlay, currentEditingNode = null;
container.format = (val, field) => {
if (field.formatter) return field.formatter(val, field);
const typeInfo = DataGridConfig._fieldTypes.get(field.settings?.formType || field.type || 'text');
if (typeInfo && typeInfo.formatter) return typeInfo.formatter(val, field);
return val == null ? '' : (typeof val === 'object' ? JSON.stringify(val) : String(val));
};
container.onScroll = () => {
perf.onScroll(); scroll.refresh();
container.hideColumnMenu();
container.hideEditor(true);
const prev = container.querySelector('.dt-spacer-prev'), post = container.querySelector('.dt-spacer-post');
if (prev) { prev.style.height = (state.prevHeight || 0) + 'px'; prev.style.display = state.prevHeight > 0 ? 'block' : 'none'; }
if (post) { post.style.height = (state.postHeight || 0) + 'px'; post.style.display = state.postHeight > 0 ? 'block' : 'none'; }
};
container.applySortFilter = (options = {}) => {
if (state._internalUpdate) return;
const targetFilters = { ...state.filterConfig, ...(options.filters || {}) };
const targetSort = options.sort !== undefined ? (options.sort ? { fieldId: state.activeFieldId, direction: options.sort } : { fieldId: null, direction: null }) : state.sortConfig;
let filtered = [...state._originalList];
Object.entries(targetFilters).forEach(([fId, cfg]) => {
if (!cfg.value && (!cfg.selectedValues || cfg.selectedValues.length === 0)) return;
filtered = filtered.filter(item => {
const val = item[fId];
if (cfg.selectedValues?.length > 0) return cfg.selectedValues.includes(String(val));
const search = String(cfg.value).toLowerCase();
const target = String(val ?? '').toLowerCase();
switch (cfg.mode) {
case 'contains': return target.includes(search);
case 'equals': return target === search;
case 'starts': return target.startsWith(search);
case 'ends': return target.endsWith(search);
case '=': return Number(val) === Number(cfg.value);
case '>': return Number(val) > Number(cfg.value);
case '<': return Number(val) < Number(cfg.value);
case 'between': return Number(val) >= Number(cfg.value) && Number(val) <= Number(cfg.value2);
default: return true;
}
});
});
if (targetSort.fieldId && targetSort.direction) {
const fId = targetSort.fieldId;
const dir = targetSort.direction === 'asc' ? 1 : -1;
filtered.sort((a, b) => {
if (a[fId] == b[fId]) return 0;
return a[fId] > b[fId] ? dir : -dir;
});
}
state._internalUpdate = true;
state.filterConfig = targetFilters;
state.sortConfig = targetSort;
state.list = filtered;
state._internalUpdate = false;
};
container.showColumnMenu = (field, event) => {
const btn = event.currentTarget, menu = container.querySelector('.dt-column-menu');
if (menu.style.display === 'block' && state.activeFieldId === field.id) {
container.hideColumnMenu();
container.applySortFilter();
return;
}
const type = field.settings?.formType || field.type || 'text';
state.activeModes = MODE_MAP[type] || (['boolean', 'switch', 'checkbox', 'radio'].includes(type) ? [] : MODE_MAP.text);
if (!state.filterConfig[field.id]) state.filterConfig[field.id] = { mode: state.activeModes[0] || 'contains', value: '', selectedValues: [] };
state.activeField = field; state.activeFieldId = field.id;
menu.style.display = 'block';
const cellNode = btn.closest('.dt-cell'), rect = cellNode.getBoundingClientRect(), rootRect = container.getBoundingClientRect();
const menuWidth = menu.offsetWidth || 260;
let leftPos = rect.right - rootRect.left - menuWidth;
if (leftPos < 0) leftPos = Math.max(0, rect.left - rootRect.left);
menu.style.left = leftPos + 'px'; menu.style.top = (rect.bottom - rootRect.top + 5) + 'px';
const onGlobalClick = (ev) => { if (menu.contains(ev.target) || btn.contains(ev.target)) return; container.hideColumnMenu(); container.applySortFilter(); document.removeEventListener('mousedown', onGlobalClick); };
document.addEventListener('mousedown', onGlobalClick);
setTimeout(() => menu.querySelector('input')?.focus(), 50);
};
container.toggleSelectedValue = (val) => {
const filter = state.filterConfig[state.activeFieldId]; if (!filter) return;
const idx = filter.selectedValues.indexOf(val); if (idx === -1) filter.selectedValues.push(val); else filter.selectedValues.splice(idx, 1);
state.filterConfig = { ...state.filterConfig }; container.applySortFilter();
};
container.filterOnlyThis = (val) => { state.filterConfig[state.activeFieldId] = { mode: 'contains', value: '', selectedValues: [String(val)] }; state.filterConfig = { ...state.filterConfig }; container.applySortFilter(); };
container.hideColumnMenu = () => { const menu = container.querySelector('.dt-column-menu'); if (menu) menu.style.display = 'none'; };
container.setSort = (dir) => { const newDir = state.sortConfig.direction === dir && state.sortConfig.fieldId === state.activeFieldId ? null : dir; container.applySortFilter({ sort: newDir }); };
container.clearColumnSettings = () => { if (!state.activeFieldId) return; state.filterConfig = { ...state.filterConfig, [state.activeFieldId]: { mode: state.activeModes[0] || 'contains', value: '', value2: '', selectedValues: [] } }; container.applySortFilter(); };
container._initRow = (rowNode) => {
const row = rowNode._ref?.item;
if (row && row._editingF === undefined) { Object.defineProperty(row, '_editingF', { set: (v) => { if (v === null) container.hideEditor(true); }, configurable: true }); }
Array.from(rowNode.children).forEach(cell => { const fIdx = parseInt(cell.dataset.fidx); if (!isNaN(fIdx)) cell._ref = { ...(cell._ref || rowNode._ref), f: state.fields[fIdx], fIdx: fIdx }; });
};
state.__watch('fields', fields => {
if (!fields) return;
state._fieldsDirty = true; state._masterCellNodes = null;
container.style.setProperty('--dt-grid-template', fields.map(f => `var(--w-${f.id}, ${(f.settings?.width || f.width) || 150}px)`).join(' '));
container.style.setProperty('--dt-row-width', fields.reduce((sum, f) => sum + ((f.settings?.width || f.width) || 150), 0) + 'px');
let leftSum = 0;
fields.forEach(f => {
const pinned = f.settings?.pinned || f.pinned;
if (pinned === 'left') { container.style.setProperty(`--l-${f.id}`, leftSum + 'px'); leftSum += ((f.settings?.width || f.width) || 150); }
});
let rightSum = 0;
[...fields].reverse().forEach(f => {
const pinned = f.settings?.pinned || f.pinned;
if (pinned === 'right') { container.style.setProperty(`--r-${f.id}`, rightSum + 'px'); rightSum += ((f.settings?.width || f.width) || 150); }
});
});
state.__watch('list', list => {
if (!state._internalUpdate) {
state._originalList = [...(list || [])];
setTimeout(() => {
const stats = {};
state.fields.forEach(f => {
const counts = {};
state._originalList.forEach(item => { const val = item[f.id], key = (val == null || val === '') ? '' : String(val); counts[key] = (counts[key] || 0) + 1; });
stats[f.id] = Object.entries(counts).sort((a, b) => b[1] - a[1]).slice(0, 20).map(([val, count]) => ({ val, count }));
});
state._columnStats = stats;
}, 200);
}
scroll.init(); scroll.reset(list);
});
container.editCell = (row, field, cellNode) => {
const main = container.querySelector('.dt-main');
const overlay = container.querySelector('.dt-editor-overlay'), rect = cellNode.getBoundingClientRect(), mainRect = main.getBoundingClientRect();
currentEditingNode = cellNode;
const formType = field.settings?.formType || field.type || 'text';
const form = overlay.querySelector('AutoForm');
if (form) {
form.state.data = globalThis.NewState(globalThis.Util.clone(row));
form.state.schema = [{ ...field, type: formType, options: field.settings?.options || field.options, name: field.id, label: '' }];
}
const isComplex = ['textarea', 'TagsInput', 'checkbox', 'radio'].includes(formType);
let topPos = rect.top - mainRect.top + main.scrollTop - 1;
let leftPos = rect.left - mainRect.left + main.scrollLeft - 1;
let editorWidth = Math.max(rect.width + 2, isComplex ? 300 : 0);
const maxLeft = main.scrollWidth - editorWidth - 5;
if (leftPos > maxLeft) leftPos = Math.max(0, maxLeft);
Object.assign(overlay.style, {
display: 'flex',
left: leftPos + 'px',
top: topPos + 'px',
width: editorWidth + 'px',
height: 'auto',
minHeight: (rect.height + 2) + 'px',
maxHeight: Math.max(100, mainRect.height - (rect.top - mainRect.top) - 5) + 'px',
overflow: 'auto',
padding: '0'
});
setTimeout(() => overlay.querySelector('input, textarea, select, .form-control')?.focus(), 30);
};
container.hideEditor = (save = true) => {
if (!_editorOverlay) _editorOverlay = container.querySelector('.dt-editor-overlay');
if (!_editorOverlay || _editorOverlay.style.display === 'none') return;
const form = _editorOverlay.querySelector('AutoForm');
if (save && form && form.data) {
const input = _editorOverlay.querySelector('input:focus, select:focus, textarea:focus');
if (input) input.dispatchEvent(new Event(input.type === 'number' || input.tagName === 'SELECT' ? 'change' : 'input', { bubbles: true }));
let hasChanges = false;
const schema = form.state.schema || [];
schema.forEach(field => {
const row = currentEditingNode?.closest('.dt-row')?._ref?.item;
if (row && JSON.stringify(row[field.name]) !== JSON.stringify(form.data[field.name])) {
row[field.name] = form.data[field.name];
hasChanges = true;
}
});
if (state.isBulkEdit) {
const { minRow, maxRow, fIdx } = state.isBulkEdit;
const field = state.fields[fIdx]; const newValue = form.data[field.id];
for (let i = minRow; i <= maxRow; i++) {
if (state.list[i] && state.list[i][field.id] !== newValue) {
state.list[i][field.id] = newValue;
hasChanges = true;
}
}
}
if (hasChanges) {
state.list = [...state.list];
state.isDirty = true;
}
}
_editorOverlay.style.display = 'none';
if (form) { form.state.schema = []; form.data = null; }
currentEditingNode = null; state.isBulkEdit = null; container.focus();
};
container.onMainMouseDown = e => {
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (!row || row.classList.contains('dt-header-row')) return;
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
selection.startSelect(rIdx + state._listStartIndex, fIdx, e);
};
container.onMainMouseOver = e => {
if (state.isSelecting) {
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (row && !row.classList.contains('dt-header-row')) {
const fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
selection.updateSelect(rIdx + state._listStartIndex, fIdx);
}
}
};
container.onMainDblClick = e => {
if (!container.editable) return;
const cell = e.target.closest('.dt-cell'), row = cell?.closest('.dt-row');
if (row && !row.classList.contains('dt-header-row')) {
const item = row._ref?.item, fIdx = cell.dataset.fidx ? parseInt(cell.dataset.fidx) : Array.from(row.querySelectorAll('.dt-cell')).indexOf(cell);
const rIdx = row._ref?.rIdx ?? Array.from(container.querySelectorAll('.dt-body-row')).indexOf(row);
const absoluteRow = rIdx + state._listStartIndex;
if (item && state.fields[fIdx]) {
const bounds = selection.getSelectionBounds();
if (bounds && absoluteRow >= bounds.minRow && absoluteRow <= bounds.maxRow && fIdx >= bounds.minCol && fIdx <= bounds.maxCol) {
const affectedRows = bounds.maxRow - bounds.minRow + 1;
if (affectedRows > 1) {
state.isBulkEdit = { ...bounds, fIdx };
if (globalThis.UI?.toast) globalThis.UI.toast(`Bulk Edit: Updating ${affectedRows} rows in column "${state.fields[fIdx].name}"`, { type: 'warning' });
}
}
container.editCell(item, state.fields[fIdx], cell);
}
}
};
container.addRow = () => {
const newRow = {}; state.fields.forEach(f => newRow[f.id] = '');
state._originalList.push(newRow); state.list = [...state._originalList]; state.isDirty = true;
setTimeout(() => { scroll.reset(state.list); container.querySelector('.dt-main').scrollTop = container.querySelector('.dt-main').scrollHeight; }, 50);
};
container.deleteSelectedRow = async () => {
const bounds = selection.getSelectionBounds(); if (!bounds) return;
const count = bounds.maxRow - bounds.minRow + 1;
if (await globalThis.Dialog.confirm(`Are you sure you want to delete ${count} row(s)?`)) {
const rMin = bounds.minRow, rMax = bounds.maxRow;
const removedItems = state.list.slice(rMin, rMax + 1);
state.list = state.list.filter((_, i) => !(i >= rMin && i <= rMax));
state._originalList = state._originalList.filter(item => !removedItems.includes(item));
state.isDirty = true; selection.clearAllActive();
container.dispatchEvent(new CustomEvent('remove', { detail: { items: removedItems } }));
}
};
container.saveChanges = () => { container.dispatchEvent(new CustomEvent('save', { detail: { list: state._originalList, fields: state.fields } })); state.isDirty = false; };
const getFieldSchema = () => {
const types = globalThis.DataGrid.getFieldTypes();
const baseSchema = [
{ name: 'id', label: 'Field ID', type: 'text', setting: { required: true, placeholder: 'e.g. user_name' } },
{ name: 'name', label: 'Display Name', type: 'text', setting: { required: true, placeholder: 'e.g. 用户名' } },
{ name: 'user_type', label: 'Field Type', type: 'select', options: types.map(t => ({ label: t.label, value: t.value })) }
];
const dynamicSchema = types.reduce((acc, t) => acc.concat(t.schema || []), []);
return baseSchema.concat(dynamicSchema, [{ name: 'isIndex', label: 'Index', type: 'switch' }, { name: 'memo', label: 'Memo', type: 'text' }]);
};
const parseOptionsStr = (str) => {
if (!str) return undefined;
return str.split('\n').map(s => s.trim()).filter(Boolean).map(line => {
const idx = line.indexOf(':'); if (idx > -1) return { label: line.slice(0, idx).trim(), value: line.slice(idx + 1).trim() }; return line;
});
};
const formatOptionsStr = (opts) => { if (!opts) return ''; return opts.map(o => typeof o === 'object' ? `${o.label}:${o.value}` : o).join('\n'); };
container.addField = async () => {
container.hideColumnMenu();
const data = globalThis.NewState({ id: 'c' + Date.now().toString().slice(-4), name: 'New Field', user_type: 'text', decimals: 0, isIndex: false, memo: '', options_str: '' });
const d = container.querySelector(`Modal[id="${container.id}_field_modal"]`);
if (!d) return;
Object.assign(d.state, { title: 'Add Field', buttons: ['Cancel', 'Save'] });
const form = d.querySelector('AutoForm');
if (form) {
form.state.data = data; form.state.schema = getFieldSchema();
}
d.show();
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result), { once: true }));
if (result === 2) {
const typeInfo = globalThis.DataGrid.getFieldTypes().find(t => t.value === data.user_type);
let dbType = typeInfo?.typeForDB || 'v1024'; if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
const field = { id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { formType: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
state.fields = [...state.fields, field]; state.isDirty = true; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list];
}
};
container.editField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
const f = state.activeField; const s = f.settings || {};
const data = globalThis.NewState({ id: f.id, name: f.name, memo: f.memo || '', isIndex: !!f.isIndex, user_type: s.formType || 'text', decimals: s.decimals || 0, prefix: s.prefix || '', suffix: s.suffix || '', thousandSep: !!s.thousandSep, labelOn: s.labelOn || '', labelOff: s.labelOff || '', format: s.format || '', placeholder: s.placeholder || '', options_str: formatOptionsStr(s.options) });
const d = container.querySelector(`Modal[id="${container.id}_field_modal"]`);
if (!d) return;
Object.assign(d.state, { title: 'Edit Field', buttons: ['Cancel', 'Save'] });
const form = d.querySelector('AutoForm');
if (form) {
form.state.data = data; form.state.schema = getFieldSchema();
}
d.show();
const result = await new Promise(resolve => d.addEventListener('change', e => resolve(d.result), { once: true }));
if (result === 2) {
const idx = state.fields.findIndex(item => item.id === f.id);
if (idx !== -1) {
const typeInfo = globalThis.DataGrid.getFieldTypes().find(t => t.value === data.user_type);
let dbType = typeInfo?.typeForDB || 'v1024'; if (data.user_type === 'number') dbType = data.decimals > 0 ? 'ff' : 'bi';
const updatedField = { ...f, id: data.id, name: data.name, memo: data.memo, isIndex: !!data.isIndex, type: dbType, settings: { ...f.settings, formType: data.user_type, decimals: data.decimals, prefix: data.prefix, suffix: data.suffix, thousandSep: data.thousandSep, labelOn: data.labelOn, labelOff: data.labelOff, format: data.format, placeholder: data.placeholder, options: parseOptionsStr(data.options_str) } };
state.fields[idx] = updatedField; state.fields = [...state.fields]; state.isDirty = true; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list];
}
}
};
container.deleteField = async () => {
if (!state.activeField) return;
container.hideColumnMenu();
if (await globalThis.Dialog.confirm(`Are you sure you want to delete field "${state.activeField.name}"?`)) {
const idx = state.fields.findIndex(f => f.id === state.activeField.id);
if (idx !== -1) { state.fields.splice(idx, 1); state.fields = [...state.fields]; state.isDirty = true; container.dispatchEvent(new CustomEvent('savefields', { detail: state.fields })); state.list = [...state.list]; }
}
};
window.addEventListener('mouseup', selection.endSelect);
document.addEventListener('mousedown', e => {
const overlay = container.querySelector('.dt-editor-overlay'); const menu = container.querySelector('.dt-column-menu');
if (overlay?.style.display !== 'none' && !overlay.contains(e.target)) setTimeout(() => container.hideEditor(true));
if (!container.contains(e.target) && !overlay?.contains(e.target) && !menu?.contains(e.target)) selection.clearAllActive();
});
container.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
e.preventDefault();
selection.copy();
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
e.preventDefault();
selection.paste();
}
});
state._MODE_ICONS = MODE_ICONS;
}, globalThis.Util.makeDom(/*html*/`
<div class="dt-root d-flex flex-column h-100 border bg-body text-body overflow-visible" style="position:relative; user-select:none; outline: none; min-height: 0" tabindex="0">
<div class="dt-main flex-grow-1 overflow-auto" $onscroll="this.onScroll()"
$onmousedown="this.onMainMouseDown(event)" $onmouseover="this.onMainMouseOver(event)" $ondblclick="this.onMainDblClick(event)"
style="overflow-anchor:none; min-height: 0">
<div class="dt-header border-bottom bg-light sticky-top" style="z-index:20">
<div class="dt-header-row fw-bold text-muted small">
<template $each="this.state?.fields || []">
<div $data-id="item.id" $class="dt-cell dt-col border-end d-flex align-items-center header-cell \${(item.settings?.pinned || item.pinned) ? 'pinned-' + (item.settings?.pinned || item.pinned) : ''}" $style="((item.settings?.pinned || item.pinned) ? 'position: sticky; z-index: 11; background-color: inherit; ' : 'position:relative; ') + 'padding: 0; ' + ((item.settings?.pinned || item.pinned) === 'left' ? 'left: var(--l-' + item.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((item.settings?.pinned || item.pinned) === 'right' ? 'right: var(--r-' + item.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))">
<div class="d-flex align-items-center overflow-hidden flex-grow-1 h-100 px-2 cursor-pointer" $onclick="this.showColumnMenu(item, event)">
<i $if="this.state?.filterConfig?.[item.id] && (this.state.filterConfig[item.id].value || this.state.filterConfig[item.id].selectedValues?.length)" class="bi bi-filter me-1 text-primary"></i>
<i $if="this.state?.sortConfig?.fieldId === item.id && this.state.sortConfig.direction" $class="bi bi-sort-\${this.state.sortConfig.direction === 'asc' ? 'down' : 'up-alt'} me-1 text-primary"></i>
<span $text="item.name" class="text-truncate flex-grow-1"></span>
</div>
<button class="btn btn-xs btn-link text-muted p-0 border-0 me-1 header-menu-btn" $onclick="this.showColumnMenu(item, event)"><i class="bi bi-chevron-down"></i></button>
<Resizer $.target="thisNode.parentElement" style="position:absolute; right:0; top:0; bottom:0; width:4px; z-index:10" min="50" max="1000" $onresizing="this.onColumnResizing(item, event)" $onresize="this.onColumnResize(item, event)"></Resizer>
</div>
</template>
</div>
</div>
<div class="dt-body" style="position:relative">
<div class="dt-spacer-prev flex-shrink-0" style="display:none"></div>
<template $each="this.state?._renderedList || []" key="id" index="rIdx">
<div class="dt-row dt-body-row border-bottom bg-white" $.="this._initRow(thisNode)">
<template $each="this.state?.fields || []" as="f" index="fIdx" key="id"><div $data-fidx="fIdx" $class="dt-cell border-end px-2 d-flex align-items-center \${(f.settings?.pinned || f.pinned) ? 'pinned-' + (f.settings?.pinned || f.pinned) : ''}" $style="((f.settings?.pinned || f.pinned) ? 'position: sticky; z-index: 1; background-color: inherit; ' : '') + ((f.settings?.pinned || f.pinned) === 'left' ? 'left: var(--l-' + f.id + '); border-right: 1px solid var(--bs-border-color); box-shadow: 2px 0 5px -2px rgba(0,0,0,0.1);' : ((f.settings?.pinned || f.pinned) === 'right' ? 'right: var(--r-' + f.id + '); border-left: 1px solid var(--bs-border-color); box-shadow: -2px 0 5px -2px rgba(0,0,0,0.1);' : ''))"><span $text="this.format(item[f.id], f)" class="text-truncate"></span></div></template>
</div>
</template>
<div class="dt-spacer-post flex-shrink-0" style="display:none"></div>
</div>
<div class="dt-editor-overlay dt-editor-container" style="display: none; position: absolute; z-index: 1000; background: var(--bs-body-bg); box-shadow: 0 4px 16px rgba(0,0,0,0.25); border: 1px solid var(--bs-primary); padding: 0;"><AutoForm nobutton inline class="h-100 w-100" $onsubmit="event.preventDefault(); thisNode.closest('DataGrid').hideEditor(true)"></AutoForm></div>
</div>
<div class="dt-column-menu bg-body shadow-lg rounded p-2" style="display:none; position:absolute; z-index:2000; min-width:240px; max-width:300px; border: 1px solid var(--bs-primary)">
<template $if="this.state?.activeFieldId">
<div class="d-flex gap-1 mb-2">
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'asc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('asc')"><i class="bi bi-sort-alpha-down me-1"></i> ASC</button>
<button $class="btn btn-xs flex-grow-1 d-flex align-items-center justify-content-center \${this.state?.sortConfig?.direction === 'desc' && this.state?.sortConfig?.fieldId === this.state?.activeFieldId ? 'btn-primary' : 'btn-outline-secondary border'}" $onclick="this.setSort('desc')"><i class="bi bi-sort-alpha-up-alt me-1"></i> DESC</button>
</div>
<template $if="this.state?.activeModes?.length">
<div class="py-2 border-bottom" style="min-height: 48px">
<div class="input-group input-group-sm mb-1">
<input type="text" class="form-control" $placeholder="(this.state?.filterConfig?.[this.state?.activeFieldId]?.mode || 'Search').toUpperCase() + '...'" $bind="this.state.filterConfig[this.state.activeFieldId].value" $oninput="this.applySortFilter()" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
<button class="btn btn-outline-secondary border dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Search Mode">
<i $class="bi \${this.state?._MODE_ICONS?.[this.state?.filterConfig?.[this.state?.activeFieldId]?.mode] || 'bi-filter'}"></i>
</button>
<ul class="dropdown-menu dropdown-menu-end shadow">
<template $each="this.state?.activeModes || []" as="m">
<li><button class="dropdown-item d-flex align-items-center" type="button" $onclick="this.state.filterConfig[this.state.activeFieldId].mode = m; this.state.filterConfig = {...this.state.filterConfig}">
<i $class="bi \${this.state?._MODE_ICONS?.[m] || 'bi-filter'} me-2"></i> <span $text="m.toUpperCase()"></span>
</button></li>
</template>
</ul>
</div>
<input $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.mode === 'between'" type="text" class="form-control form-control-sm" placeholder="And..." $bind="this.state.filterConfig[this.state.activeFieldId].value2" $oninput="this.applySortFilter()" $onkeydown="if(event.key==='Enter'){this.applySortFilter();this.hideColumnMenu();}">
</div>
</template>
<div class="mt-2" style="max-height: 180px; overflow-y: auto;">
<template $each="this.state?._columnStats?.[this.state?.activeFieldId] || []">
<label class="d-flex align-items-center mb-1 small cursor-pointer p-1 rounded-1 menu-item-row" onmouseover="this.style.background='var(--bs-light)'" onmouseout="this.style.background='transparent'">
<input type="checkbox" class="form-check-input me-2" $checked="this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.includes(String(item.val))" $onclick="this.toggleSelectedValue(String(item.val))">
<span class="text-truncate flex-grow-1"><span $text="item.val || '(Empty)'"></span> <span class="text-muted ms-1" style="font-size: 0.7rem" $text="'(' + item.count + ')'"></span></span>
<button class="btn btn-xs btn-link p-0 text-primary only-btn" style="font-size: 10px; text-decoration: none" $onclick="this.filterOnlyThis(item.val); event.preventDefault(); event.stopPropagation();">Only</button>
</label>
</template>
</div>
<div $if="this.state?.filterConfig?.[this.state?.activeFieldId]?.value || this.state?.filterConfig?.[this.state?.activeFieldId]?.selectedValues?.length" class="mt-2 pt-1 border-top text-center">
<span class="cursor-pointer text-primary small fw-bold" $onclick="this.clearColumnSettings()"><i class="bi bi-x-circle me-1"></i> Clear Filter</span>
</div>
<div $if="this.state?.editable" class="mt-3 pt-2 border-top d-flex gap-1 justify-content-between">
<button class="btn btn-sm btn-light border-0 flex-grow-1" title="Edit Field" $onclick="this.editField()"><i class="bi bi-pencil"></i></button>
<button class="btn btn-sm btn-light border-0 flex-grow-1" title="Add Field" $onclick="this.addField()"><i class="bi bi-plus-lg"></i></button>
<button class="btn btn-sm btn-light border-0 flex-grow-1 text-danger" title="Delete Field" $onclick="this.deleteField()"><i class="bi bi-trash"></i></button>
</div>
</template>
</div>
<Modal $.id="this.id + '_field_modal'">
<div slot="body"><AutoForm nobutton class="p-3"></AutoForm></div>
<div slot="footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal" $onclick="thisNode.closest('Modal').result=1">Cancel</button>
<button type="button" class="btn btn-primary" data-bs-dismiss="modal" $onclick="thisNode.closest('Modal').result=2">Save</button>
</div>
</Modal>
<div class="dt-footer border-top bg-light d-flex align-items-center px-3 py-1 shadow-sm" style="height:40px; z-index: 10">
<div class="d-flex align-items-center gap-3 flex-grow-1">
<div $if="this.state?.editable" class="d-flex align-items-center gap-1">
<button class="btn btn-sm btn-light border-0 d-flex align-items-center px-2 py-1" $onclick="this.addRow()" title="Add Row"><i class="bi bi-plus-lg text-primary me-1"></i> Add</button>
<button class="btn btn-sm btn-light border-0 d-flex align-items-center px-2 py-1" $onclick="this.deleteSelectedRow()" $disabled="!this.state?.selectedRowCount" title="Delete Selected Rows"><i class="bi bi-trash text-danger me-1"></i> Delete</button>
</div>
<div $if="this.state?.editable" class="vr h-50 my-auto text-muted opacity-25"></div>
<div class="d-flex align-items-center gap-2 text-muted" style="font-size: 0.75rem">
<i class="bi bi-check-all fs-6"></i>
<span $text="(this.state?.selectedRowCount || 0) + ' selected / ' + (this.state?.list?.length || 0) + ' total'"></span>
</div>
</div>
<div $if="this.state?.editable" class="d-flex align-items-center gap-2">
<button $if="this.state?.isDirty" class="btn btn-sm btn-primary border-0 px-3 shadow-sm d-flex align-items-center fw-bold" $onclick="this.saveChanges()"><i class="bi bi-cloud-upload me-1"></i> Save</button>
<button $if="!this.state?.isDirty" class="btn btn-sm btn-light border-0 px-3 text-muted disabled d-flex align-items-center" disabled><i class="bi bi-cloud-check me-1"></i> Saved</button>
</div>
</div>
</div>
`), globalThis.Util.makeDom(/*html*/`
<style>
DataGrid { display: block; }
.dt-root { font-size: 0.875rem; }
.dt-row, .dt-header-row { display: grid; grid-template-columns: var(--dt-grid-template); width: var(--dt-row-width, max-content); min-width: 100%; height: 40px; contain: paint layout; }
.dt-main { position: relative; }
.dt-header-row { background-color: var(--bs-tertiary-bg); border-bottom: 1px solid var(--bs-border-color); }
.dt-cell { background: inherit; white-space: nowrap; flex-shrink: 0; contain: content; }
.dt-cell-selected { background-color: rgba(var(--bs-primary-rgb), 0.15) !important; outline: 1px solid var(--bs-primary); outline-offset: -1px; }
.dt-body-row:hover { background-color: var(--bs-secondary-bg) !important; }
.header-cell .header-menu-btn { opacity: 0; transition: opacity 0.2s; }
.header-cell:hover .header-menu-btn { opacity: 1; }
.dt-column-menu { background-color: var(--bs-body-bg); border: 1px solid var(--bs-primary); box-shadow: 0 10px 40px rgba(0,0,0,0.2) !important; z-index: 2100 !important; }
.btn-xs { padding: 1px 5px; line-height: 1.5; }
.cursor-pointer { cursor: pointer; }
.dt-filter-tabs i { font-size: 1.1rem; }
.dt-filter-tabs div:hover i { color: var(--bs-primary); }
.menu-item-row .only-btn { opacity: 0; }
.menu-item-row:hover .only-btn { opacity: 1; }
.dt-editor-overlay .auto-form-root form { gap: 0 !important; margin: 0 !important; height: 100%; }
.dt-editor-overlay [control-wrapper] { width: 100%; margin: 0 !important; min-height: 100% !important; align-items: stretch !important; }
.dt-editor-overlay [control-wrapper] > .d-flex { padding: 0.375rem 0.5rem; justify-content: flex-start !important; align-items: center !important; }
.dt-editor-overlay [control-wrapper] > .form-switch { padding-left: 2.5rem !important; }
.dt-editor-overlay [control-wrapper] > textarea { min-height: 100px; resize: vertical; }
</style>
`))
globalThis.DataGrid = DataGridConfig;

1
components/data/DataGrid.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

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

View File

@ -0,0 +1,94 @@
name: DataGrid
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.
editing:
formTypes: [text, number, select, checkbox, radio, switch, textarea, date, datetime, TagsInput, DatePicker, ColorPicker, IconPicker]
rule: Double-click an editable cell, then click outside the editor or press Enter to save its bound value.
filtering:
rule: Click a column header, enter a value in its menu, and the grid filters immediately. Use the menu controls for sort and reset.
field:
required: [id, name]
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.
onScroll: Refresh the virtual row window after scrolling.
events:
save:
detail: "{ list, fields }"
savefields:
detail: fields
remove:
detail: "{ items }"
global:
DataGrid:
methods:
registerFieldType: Register a field type configuration.
getFieldTypes: Return registered field types.
related:
- ../base/AutoForm.yaml
- ../base/Modal.yaml
- ../base/Dialog.yaml
- ../../utilities/VirtualScroll.yaml
- ../base/Resizer.yaml
- ../form/DatePicker.yaml
- ../form/ColorPicker.yaml
- ../form/IconPicker.yaml
- ../form/TagsInput.yaml
rules:
- Bind fields and list with $.state.fields and $.state.list.
- Give the DataGrid or its parent an explicit height so virtual scrolling has a viewport.
- Treat underscore-prefixed state fields as internal read-only diagnostics.
- Custom field editors must be registered in AutoForm before use.
example: |
<script>
const orderFields = [
{ id: 'name', name: 'Name', type: 'text', settings: { formType: 'text' } },
{ id: 'total', name: 'Total', type: 'number', settings: { formType: 'number', prefix: '$', decimals: 2 } },
{ id: 'status', name: 'Status', type: 'text', settings: { formType: 'select', options: ['draft', 'paid'] } },
{ id: 'paid', name: 'Paid', type: 'boolean', settings: { formType: 'switch' } },
{ id: 'tags', name: 'Tags', type: 'object', settings: { formType: 'TagsInput' } },
{ id: 'due', name: 'Due', type: 'date', settings: { formType: 'DatePicker' } }
]
const orders = [{ name: 'Ada', total: 42, status: 'paid', paid: true, tags: ['priority'], due: '2026-07-12' }]
const saveRows = rows => console.log(rows)
</script>
<div style="height: 480px">
<DataGrid id="orders" editable $.state.fields="orderFields" $.state.list="orders" $onsave="saveRows(event.detail.list)"></DataGrid>
</div>
tests:
- DataGrid.test.html

9
test/console.spec.cjs Normal file
View File

@ -0,0 +1,9 @@
const { test, expect } = require('../tools/playwright.cjs');
test('the UI test console runs every registered component test', async ({ page }) => {
await page.goto('/ui/ui.test.html');
await page.locator('#all').click();
await page.waitForFunction(() => [...document.querySelectorAll('[data-test]')].every(row => row.querySelector('.status').textContent !== '…'));
await expect(page.locator('[data-test] .status')).toHaveText(Array(14).fill('✓'));
});

20
test/groups.html Normal file
View File

@ -0,0 +1,20 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<script src="/ui/ui.js" components="base,form,data.DataGrid"></script>
</head>
<body>
<API id="api"></API>
<AutoForm id="form"></AutoForm>
<List id="list"></List>
<Modal id="modal"></Modal>
<Nav id="nav"></Nav>
<Resizer id="resizer"></Resizer>
<DatePicker id="datePicker"></DatePicker>
<ColorPicker id="colorPicker"></ColorPicker>
<IconPicker id="iconPicker"></IconPicker>
<TagsInput id="tagsInput"></TagsInput>
<DataGrid id="dataGrid"></DataGrid>
</body>
</html>

11
test/groups.spec.cjs Normal file
View File

@ -0,0 +1,11 @@
const { test, expect } = require('../tools/playwright.cjs');
test('ui.js expands base and form component groups', async ({ page }) => {
await page.goto('/ui/test/groups.html');
const names = ['API', 'AutoForm', 'List', 'Modal', 'Dialog', 'Toast', 'Nav', 'Resizer', 'DatePicker', 'ColorPicker', 'IconPicker', 'TagsInput', 'DataGrid'];
expect(await page.evaluate(items => items.every(name => Component.exists(name)), names)).toBe(true);
await expect(page.locator('#datePicker')).toBeAttached();
await expect(page.locator('#tagsInput')).toBeAttached();
await expect(page.locator('#dataGrid')).toBeAttached();
});

View File

@ -5,7 +5,8 @@ const index = process.argv.indexOf('--test');
const requested = index === -1 ? '' : process.argv[index + 1]; const requested = index === -1 ? '' : process.argv[index + 1];
let target = 'ui.test.html'; let target = 'ui.test.html';
if (requested) { if (requested) {
const name = requested.includes('.') ? requested : 'components.' + requested; const aliases = { DataGrid: 'components.data.DataGrid', 'data.DataGrid': 'components.data.DataGrid' };
const name = aliases[requested] || (requested.includes('.') ? requested : 'components.' + requested);
const parts = name.split('.'); const parts = name.split('.');
if (parts[0] !== 'components' && parts[0] !== 'utilities') throw new Error('Use npm test -- List, form.DatePicker, or components.form.DatePicker'); if (parts[0] !== 'components' && parts[0] !== 'utilities') throw new Error('Use npm test -- List, form.DatePicker, or components.form.DatePicker');
target += '?test=' + encodeURIComponent(name); target += '?test=' + encodeURIComponent(name);

32
ui.js
View File

@ -1,11 +1,11 @@
(() => { (() => {
// Updated by `npm run build`. Source files stay directly executable. // Updated by `npm run build`. Source files stay directly executable.
const build = { version: '1.0.0', files: {"frameworks/Bootstrap.js":"mrgoatzb","frameworks/State.js":"mrgokg8d","utilities/HTTP.js":"mrg6t8zm","utilities/MouseMover.js":"mrg6t8zm","utilities/VirtualScroll.js":"mrg6t8zn","components/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 build = { version: '1.0.0', files: {"frameworks/Bootstrap.js":"mrgoatzb","frameworks/State.js":"mrgokg8d","utilities/HTTP.js":"mrg6t8zm","utilities/MouseMover.js":"mrg6t8zm","utilities/VirtualScroll.js":"mrg6t8zn","components/base/API.js":"mrggjm8m","components/base/AutoForm.js":"mrgoatz5","components/base/Dialog.js":"mrh7tjfb","components/base/List.js":"mrge46xl","components/base/Modal.js":"mrh4mibf","components/base/Nav.js":"mrh5kbf0","components/base/Resizer.js":"mrghfzrh","components/base/Toast.js":"mrghiycy","components/data/DataGrid.js":"mrhbi0rq","components/form/ColorPicker.js":"mrgn837c","components/form/DatePicker.js":"mrgn837c","components/form/IconPicker.js":"mrgoatz5","components/form/TagsInput.js":"mrgn837b"} };
const script = document.currentScript; const script = document.currentScript;
if (!script || document.readyState !== 'loading') { if (!script || document.readyState !== 'loading') {
throw new Error('ui.js must be loaded by a normal <script> while the document is parsing'); throw new Error('ui.js must be loaded by a normal <script> while the document is parsing');
} }
const requested = (script?.getAttribute('components') || '') const requestedNames = (script?.getAttribute('components') || '')
.split(',') .split(',')
.map(name => name.trim()) .map(name => name.trim())
.filter(Boolean); .filter(Boolean);
@ -14,20 +14,29 @@
HTTP: { path: 'utilities/HTTP.js' }, HTTP: { path: 'utilities/HTTP.js' },
VirtualScroll: { path: 'utilities/VirtualScroll.js' }, VirtualScroll: { path: 'utilities/VirtualScroll.js' },
MouseMover: { path: 'utilities/MouseMover.js' }, MouseMover: { path: 'utilities/MouseMover.js' },
API: { path: 'components/API.js', needs: ['Toast'] }, API: { path: 'components/base/API.js', needs: ['Toast'] },
AutoForm: { path: 'components/AutoForm.js', needs: ['Toast'] }, AutoForm: { path: 'components/base/AutoForm.js', needs: ['Toast'] },
List: { path: 'components/List.js', needs: ['VirtualScroll'] }, List: { path: 'components/base/List.js', needs: ['VirtualScroll'] },
Modal: { path: 'components/Modal.js' }, Modal: { path: 'components/base/Modal.js' },
Dialog: { path: 'components/Dialog.js', needs: ['Modal'] }, Dialog: { path: 'components/base/Dialog.js', needs: ['Modal'] },
Toast: { path: 'components/Toast.js' }, Toast: { path: 'components/base/Toast.js' },
Nav: { path: 'components/Nav.js' }, Nav: { path: 'components/base/Nav.js' },
Resizer: { path: 'components/Resizer.js' }, Resizer: { path: 'components/base/Resizer.js' },
DataGrid: { path: 'components/data/DataGrid.js', needs: ['AutoForm', 'Dialog', 'Resizer', 'DatePicker', 'ColorPicker', 'IconPicker', 'TagsInput'] },
DatePicker: { path: 'components/form/DatePicker.js', needs: ['AutoForm'] }, DatePicker: { path: 'components/form/DatePicker.js', needs: ['AutoForm'] },
ColorPicker: { path: 'components/form/ColorPicker.js', needs: ['AutoForm'] }, ColorPicker: { path: 'components/form/ColorPicker.js', needs: ['AutoForm'] },
IconPicker: { path: 'components/form/IconPicker.js', needs: ['AutoForm'] }, IconPicker: { path: 'components/form/IconPicker.js', needs: ['AutoForm'] },
TagsInput: { path: 'components/form/TagsInput.js', needs: ['AutoForm'] } TagsInput: { path: 'components/form/TagsInput.js', needs: ['AutoForm'] }
}; };
const groups = {
base: ['API', 'AutoForm', 'List', 'Modal', 'Dialog', 'Toast', 'Nav', 'Resizer'],
form: ['DatePicker', 'ColorPicker', 'IconPicker', 'TagsInput'],
data: ['DataGrid']
};
const qualified = Object.fromEntries(Object.entries(groups).flatMap(([group, names]) => names.map(name => [`${group}.${name}`, name])));
const requested = requestedNames.flatMap(name => groups[name] || [qualified[name] || name]);
if (!script.hasAttribute('components')) throw new Error('ui.js requires components="..."'); if (!script.hasAttribute('components')) throw new Error('ui.js requires components="..."');
const base = new URL('.', script.src); const base = new URL('.', script.src);
@ -49,7 +58,8 @@
const load = name => { const load = name => {
if (loaded.has(name)) return if (loaded.has(name)) return
loaded.add(name) loaded.add(name)
const mod = modules[name] || { path: 'components/' + name.split('.').join('/') + '.js' } const mod = modules[name]
if (!mod) throw new Error(`Unknown UI component: ${name}`)
;(mod.needs || []).forEach(load) ;(mod.needs || []).forEach(load)
writeScript(mod.path) writeScript(mod.path)
} }

2
ui.min.js vendored
View File

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

View File

@ -13,15 +13,19 @@
<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 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"> <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="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.API" href="components/base/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.AutoForm" href="components/base/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.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.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.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.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.Toast" href="components/base/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.Resizer" href="components/base/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> <a data-test="components.List" href="components/base/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>
<a data-test="components.Modal" href="components/base/Modal.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>Modal</a>
<a data-test="components.Dialog" href="components/base/Dialog.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>Dialog</a>
<a data-test="components.Nav" href="components/base/Nav.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>Nav</a>
<a data-test="components.data.DataGrid" href="components/data/DataGrid.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>DataGrid</a>
</div> </div>
</div> </div>
</aside> </aside>

View File

@ -1,6 +1,6 @@
name: VirtualScroll name: VirtualScroll
purpose: Variable-height virtual scrolling utility used by List and DataTable. purpose: Variable-height virtual scrolling utility used by List and DataGrid.
factory: factory:
signature: VirtualScroll(options) signature: VirtualScroll(options)