Compare commits

..

No commits in common. "main" and "v1.0.21" have entirely different histories.

29 changed files with 703 additions and 849 deletions

View File

@ -250,8 +250,6 @@
- **`collapsible`** (Boolean): 仅在 `tree` 模式下有效,开启树形节点的展开/收起能力。 - **`collapsible`** (Boolean): 仅在 `tree` 模式下有效,开启树形节点的展开/收起能力。
- **`auto-select`** (Boolean): 开启后,点击列表项时自动将该项的 `id` 记录至 `state.selectedItem`,如果重复点击则会置空。 - **`auto-select`** (Boolean): 开启后,点击列表项时自动将该项的 `id` 记录至 `state.selectedItem`,如果重复点击则会置空。
- **`auto-select-group`** (Boolean): 开启后,点击分组时自动将分组的 `id` 记录至 `state.selectedGroup` - **`auto-select-group`** (Boolean): 开启后,点击分组时自动将分组的 `id` 记录至 `state.selectedGroup`
- **`filter`** (String): 筛选关键字。支持通过 attribute 传入以自动同步至 `state.filter`
- **`order`** (String): 排序规则。支持通过 attribute 传入以自动同步至 `state.order`
### 4. 内部状态模型 (`State`) ### 4. 内部状态模型 (`State`)
- `list` (读写): 原始列表数据数组。 - `list` (读写): 原始列表数据数组。
@ -259,8 +257,6 @@
- `collapsed` (读写): 仅在 `tree``collapsible` 时使用,存储节点 ID 折叠状态的 Map Proxy。 - `collapsed` (读写): 仅在 `tree``collapsible` 时使用,存储节点 ID 折叠状态的 Map Proxy。
- `selectedItem` (读写): 当前选中的列表项 ID。 - `selectedItem` (读写): 当前选中的列表项 ID。
- `selectedGroup` (读写): 当前选中的分组 ID。 - `selectedGroup` (读写): 当前选中的分组 ID。
- `filter` (读写): 筛选关键字(进行 label 和 summary 的不区分大小写模糊匹配)或自定义过滤函数 `(item) => boolean`
- `order` (读写): 排序规则。可为排序字段名(例如 `'name'`;前缀 `-` 表示降序,如 `'-name'`;或者后缀 `'desc'`/`'asc'` 语法,如 `'name desc'`),也可以为自定义排序函数 `(a, b) => number`
- `_flatList` (只读): 列表核心逻辑处理后的扁平化数组。 - `_flatList` (只读): 列表核心逻辑处理后的扁平化数组。
- `_renderedList` (只读): 实际在 DOM 中遍历渲染的数组片段(若开启虚拟滚动,则只包含可视区切片)。 - `_renderedList` (只读): 实际在 DOM 中遍历渲染的数组片段(若开启虚拟滚动,则只包含可视区切片)。

View File

@ -1,9 +1,5 @@
# Changelog # Changelog
## [1.0.22] - 2026-06-28
### Added
- **List**: Added internal filtering and sorting capabilities on the `List` component across all three layout modes (`normal`, `group`, `tree`). Supports `filter` and `order` attributes and state fields. In `tree` mode, filtering correctly propagates up to preserve parent-descendant hierarchy. Added a MutationObserver to listen to attribute changes and sync them reactively to internal component state.
## [1.0.21] - 2026-06-27 ## [1.0.21] - 2026-06-27
### Fixed ### Fixed
- **API**: Preserved declarative preset fields injected through `$.request.*` when the `API` component initializes its internal reactive request state. - **API**: Preserved declarative preset fields injected through `$.request.*` when the `API` component initializes its internal reactive request state.

View File

@ -26,7 +26,7 @@
<!-- 2. Bootstrap 适配层 --> <!-- 2. Bootstrap 适配层 -->
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/bootstrap@1.0.9/dist/bootstrap.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@apigo.cc/bootstrap@1.0.9/dist/bootstrap.min.js"></script>
<!-- 3. 本业务组件库 --> <!-- 3. 本业务组件库 -->
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/base@1.0.22/dist/base.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@apigo.cc/base@1.0.21/dist/base.min.js"></script>
``` ```
<script> <script>
// 4. 数据先行 (在 body 解析前定义) // 4. 数据先行 (在 body 解析前定义)
@ -205,8 +205,6 @@ const { HTTP, UI, AutoForm, State } = window.ApigoBase
``` ```
* **AI 核心要点** * **AI 核心要点**
* **属性默认值**:当数据结构符合默认字段命名(`id`, `label`, `summary`, `parent`, `group`)时,**无须填写**任何映射属性(如 `idfield`, `labelfield`, `parentfield` 等),实际编写代码时应当将其省略。 * **属性默认值**:当数据结构符合默认字段命名(`id`, `label`, `summary`, `parent`, `group`)时,**无须填写**任何映射属性(如 `idfield`, `labelfield`, `parentfield` 等),实际编写代码时应当将其省略。
* **筛选与过滤 (`filter`)**:可绑定筛选关键字字符串(对 label 和 summary 进行不区分大小写匹配,如 `$filter="State.searchKey || ''"`,加 `|| ''` 可方便地在输入清空时彻底重置)或自定义过滤函数 `(item) => boolean`。在 `tree` 树形模式下,过滤匹配会自动向上保留所有的祖先节点链,且仅保留匹配项分支;在 `group` 分组模式下,若分组自身或其下有任意项匹配则显示分组。
* **排序与整理 (`order`)**:可绑定排序字段名字符串(如 `'name'`;前缀 `-` 表示降序,如 `'-name'`;或者后缀 `'desc'`/`'asc'` 语法,如 `'name desc'`),也可以绑定自定义排序比较函数 `(a, b) => number`。在 `tree` 树形模式下对同级子节点进行排序。
* 虚拟滚动 (`fast`) 强制容器必须声明为 `overflow-auto` 类。 * 虚拟滚动 (`fast`) 强制容器必须声明为 `overflow-auto` 类。
--- ---

38
TEST.md
View File

@ -1,23 +1,21 @@
# base Test Notes # base Benchmark & Test Results
当前测试只保留可维护的 Playwright 冒烟用例,统一直接打开 `test/` 里的 UMD 示例页,不再依赖历史上的浏览器内自测脚本或混合 ESM 入口。 ## 基准测试 (Benchmark)
*测试环境: Playwright / Chromium*
## 运行方式 | 指标 | v1.0.0 | v1.0.1 | v1.0.3 | v1.0.4 | v1.0.7 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **FastList Render & Scroll (10k items)** | ~535ms | ~473ms | ~1513ms | ~54ms | ~53ms |
| **FastGroupedList Render & Scroll (10k)** | ~705ms | ~51ms | ~51ms | ~1550ms | ~1560ms |
| **FastTree Render & Scroll (10k items)** | ~927ms | ~50ms | ~51ms | ~1560ms | ~1570ms |
| **CollapseTree Render & Scroll (1.2k)** | ~51ms | ~50ms | ~50ms | ~51ms | ~51ms |
```bash ## 测试覆盖 (Coverage)
npm test - [x] HTTP Request (GET/POST)
``` - [x] API Component (Auto mode)
- [x] Modal / Dialog / Toast UI
测试命令会先执行 `npm run build`,然后自动把最新的 `state/bootstrap/base` 产物同步到 `test/lib`,最后再跑 Playwright。这样可以避免 `test/lib` 副本过期,但又不依赖跨目录静态服务配置。 - [x] AutoForm (Rendering & Validation)
- [x] DatePicker / ColorPicker / IconPicker (New)
Playwright 会自动启动本地 Vite 静态服务,并执行以下用例: - [x] FastList (Virtual Scrolling)
- [x] Tree / Nav / Resizer
- `test/api.spec.js`:验证 `<API>` 会保留声明式 `$.request.*` 预设字段。 - [x] Virtual List dynamic sizing check
- `test/form.spec.js`:验证 `AutoForm` 的垂直、水平、强制水平、inline 等布局。
- `test/list.spec.js`:验证普通列表、分组列表、树形列表和虚拟滚动列表。
- `test/capability.spec.js`:验证示例页里的 `Nav` 切换和主要区域渲染。
## 取舍原则
- 删除了端口写死、依赖旧 ESM 入口、只输出调试信息却没有稳定断言价值的历史用例。
- 不再把性能 benchmark 当成默认测试流程的一部分,避免未来维护被脆弱用例卡住。

156
dist/base.js vendored
View File

@ -762,142 +762,37 @@
container.collapsed = globalThis.NewState({}); container.collapsed = globalThis.NewState({});
container.state.renderedList = []; container.state.renderedList = [];
const updateFlatList = () => { const updateFlatList = () => {
var _a;
globalThis.Util.updateDefaults(container, defaultSets); globalThis.Util.updateDefaults(container, defaultSets);
let list = container.state.list || []; const list = container.state.list || [], flatList = [];
const filter = container.state.filter;
const order = container.state.order;
if (order) {
if (typeof order === "function") {
list = [...list].sort(order);
} else if (typeof order === "string") {
const parts = order.trim().split(/\s+/);
const field = parts[0];
const isDesc = ((_a = parts[1]) == null ? void 0 : _a.toLowerCase()) === "desc" || field.startsWith("-");
const cleanField = field.startsWith("-") ? field.slice(1) : field;
list = [...list].sort((a, b) => {
const valA = a[cleanField];
const valB = b[cleanField];
if (valA === void 0 || valA === null) return isDesc ? 1 : -1;
if (valB === void 0 || valB === null) return isDesc ? -1 : 1;
if (typeof valA === "string" && typeof valB === "string") {
return isDesc ? valB.localeCompare(valA) : valA.localeCompare(valB);
}
return isDesc ? valB > valA ? 1 : -1 : valA > valB ? 1 : -1;
});
}
}
const flatList = [];
if (container.mode === "group") { if (container.mode === "group") {
let filteredList = list;
if (filter) {
if (typeof filter === "function") {
filteredList = list.filter(filter);
} else if (typeof filter === "string") {
const q = filter.trim().toLowerCase();
if (q) {
filteredList = list.filter((item) => {
const label = String(item[container.labelfield] || "").toLowerCase();
const summary = String(item[container.summaryfield] || "").toLowerCase();
return label.includes(q) || summary.includes(q);
});
}
}
}
const itemMap = {}; const itemMap = {};
filteredList.forEach((item) => { list.forEach((item) => {
var _a2; var _a;
return (itemMap[_a2 = item[container.groupfield]] ?? (itemMap[_a2] = [])).push(item); return (itemMap[_a = item[container.groupfield]] ?? (itemMap[_a] = [])).push(item);
}); });
(container.state.groups || []).forEach((group) => { (container.state.groups || []).forEach((group) => {
const items = itemMap[group[container.groupidfield]];
const groupLabel = String(group[container.grouplabelfield] || "").toLowerCase();
const groupSummary = String(group[container.groupsummaryfield] || "").toLowerCase();
const q = typeof filter === "string" ? filter.trim().toLowerCase() : "";
const groupMatches = q ? groupLabel.includes(q) || groupSummary.includes(q) : false;
if (items && items.length > 0 || groupMatches || !filter) {
flatList.push({ type: "group", ...group }); flatList.push({ type: "group", ...group });
const items = itemMap[group[container.groupidfield]];
if (items) items.forEach((item) => flatList.push({ type: "item", ...item })); if (items) items.forEach((item) => flatList.push({ type: "item", ...item }));
}
}); });
} else if (container.mode === "tree") { } else if (container.mode === "tree") {
const itemMap = {};
list.forEach((item) => {
itemMap[item[container.idfield]] = item;
});
const matches = /* @__PURE__ */ new Set();
list.forEach((item) => {
let match = false;
if (!filter) {
match = true;
} else if (typeof filter === "function") {
match = filter(item);
} else if (typeof filter === "string") {
const q = filter.trim().toLowerCase();
if (!q) {
match = true;
} else {
const label = String(item[container.labelfield] || "").toLowerCase();
const summary = String(item[container.summaryfield] || "").toLowerCase();
match = label.includes(q) || summary.includes(q);
}
}
if (match) {
matches.add(item[container.idfield]);
}
});
const keep = new Set(matches);
if (filter) {
matches.forEach((id) => {
let curr = itemMap[id];
while (curr) {
const parentId = curr[container.parentfield];
if (!parentId) break;
if (keep.has(parentId)) break;
keep.add(parentId);
curr = itemMap[parentId];
}
});
}
const childrenMap = {}; const childrenMap = {};
list.forEach((item) => { list.forEach((item) => {
if (!filter || keep.has(item[container.idfield])) { var _a;
const parentId = item[container.parentfield] || ""; return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
(childrenMap[parentId] ?? (childrenMap[parentId] = [])).push(item);
}
}); });
const traverse = (items, level, parents) => items.forEach((item) => { const traverse = (items, level, parents) => items.forEach((item) => {
var _a2; var _a;
const id = item[container.idfield]; const id = item[container.idfield], hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.length);
const hasChildren = !!((_a2 = childrenMap[id]) == null ? void 0 : _a2.length);
const isCollapsed = container.collapsed[id]; const isCollapsed = container.collapsed[id];
flatList.push({ type: "item", ...item, _level: level, _hasChildren: hasChildren, _parents: parents }); flatList.push({ type: "item", ...item, _level: level, _hasChildren: hasChildren, _parents: parents });
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]); if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]);
}); });
traverse(childrenMap[""] || [], 0, []); traverse(childrenMap[""] || [], 0, []);
} else { } else list.forEach((item) => flatList.push({ type: "item", ...item }));
let filteredList = list;
if (filter) {
if (typeof filter === "function") {
filteredList = list.filter(filter);
} else if (typeof filter === "string") {
const q = filter.trim().toLowerCase();
if (q) {
filteredList = list.filter((item) => {
const label = String(item[container.labelfield] || "").toLowerCase();
const summary = String(item[container.summaryfield] || "").toLowerCase();
return label.includes(q) || summary.includes(q);
});
}
}
}
filteredList.forEach((item) => flatList.push({ type: "item", ...item }));
}
container.state.flatList = flatList; container.state.flatList = flatList;
}; };
container.state.__watch("list", updateFlatList); container.state.__watch("list", updateFlatList);
container.state.__watch("filter", updateFlatList);
container.state.__watch("order", updateFlatList);
const vs = container.fast ? globalThis.VirtualScroll() : null; const vs = container.fast ? globalThis.VirtualScroll() : null;
let refreshing = false; let refreshing = false;
container.refresh = () => { container.refresh = () => {
@ -945,31 +840,6 @@
updateFlatList(); updateFlatList();
} }
}; };
Object.defineProperty(container, "filter", {
get: () => container.state.filter,
set: (v) => {
container.state.filter = v;
}
});
Object.defineProperty(container, "order", {
get: () => container.state.order,
set: (v) => {
container.state.order = v;
}
});
const observer = new MutationObserver(() => {
const filterAttr = container.getAttribute("filter");
if (typeof container.state.filter !== "function" && filterAttr !== container.state.filter) {
container.state.filter = filterAttr || void 0;
}
const orderAttr = container.getAttribute("order");
if (typeof container.state.order !== "function" && orderAttr !== container.state.order) {
container.state.order = orderAttr || void 0;
}
});
observer.observe(container, { attributes: true, attributeFilter: ["filter", "order"] });
container.state.filter = container.getAttribute("filter") || void 0;
container.state.order = container.getAttribute("order") || void 0;
updateFlatList(); updateFlatList();
}, globalThis.Util.makeDom( }, globalThis.Util.makeDom(
/*html*/ /*html*/
@ -1030,13 +900,13 @@
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''"> <div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<template $if="this.state?.brand?.image"> <template $if="this.state?.brand?.image">
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'mx-1'}" style="height:30px;width:auto;max-width:300px"> <img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
</template> </template>
<template $if="this.state?.brand?.icon"> <template $if="this.state?.brand?.icon">
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'mx-1'}"></i> <i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'me-2'}"></i>
</template> </template>
<template $if="this.state?.brand?.label"> <template $if="this.state?.brand?.label">
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'mx-1'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span> <span $class="\${this.vertical ? 'mb-2 fw-bold' : 'me-2'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
</template> </template>
</div> </div>
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''"> <div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">

2
dist/base.min.js vendored

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
{ {
"name": "@apigo.cc/base", "name": "@apigo.cc/base",
"version": "1.0.22", "version": "1.0.20",
"type": "module", "type": "module",
"main": "dist/base.js", "main": "dist/base.js",
"files": [ "files": [
@ -9,7 +9,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"test": "npm run build && node scripts/prepare-test-lib.js && playwright test", "test": "playwright test",
"pub": "node scripts/publish.js" "pub": "node scripts/publish.js"
}, },
"devDependencies": { "devDependencies": {

View File

@ -3,13 +3,12 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({ export default defineConfig({
testDir: './test', testDir: './test',
testMatch: '**/*.spec.js', testMatch: '**/*.spec.js',
reporter: 'line',
use: { use: {
baseURL: 'http://127.0.0.1:8082', baseURL: 'http://127.0.0.1:8082',
}, },
webServer: { webServer: {
command: 'npx vite --port 8082 --host 127.0.0.1', command: 'npx vite --port 8082 --host 127.0.0.1',
url: 'http://127.0.0.1:8082/test/form_test.html', url: 'http://127.0.0.1:8082/test/index.html',
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',

View File

@ -1,22 +0,0 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(__dirname, '..');
const testLibDir = path.join(root, 'test', 'lib');
const copies = [
['../../state/dist/state.js', 'state.js'],
['../../bootstrap/dist/bootstrap.js', 'bootstrap.js'],
['../dist/base.js', 'base.js']
];
fs.mkdirSync(testLibDir, { recursive: true });
for (const [sourceRelPath, targetName] of copies) {
const source = path.resolve(__dirname, sourceRelPath);
const target = path.join(testLibDir, targetName);
if (!fs.existsSync(source)) throw new Error(`Missing test dependency: ${source}`);
fs.copyFileSync(source, target);
}

View File

@ -19,142 +19,30 @@ globalThis.Component.register('List', container => {
const updateFlatList = () => { const updateFlatList = () => {
globalThis.Util.updateDefaults(container, defaultSets) globalThis.Util.updateDefaults(container, defaultSets)
let list = container.state.list || [] const list = container.state.list || [], flatList = []
const filter = container.state.filter
const order = container.state.order
if (order) {
if (typeof order === 'function') {
list = [...list].sort(order)
} else if (typeof order === 'string') {
const parts = order.trim().split(/\s+/)
const field = parts[0]
const isDesc = parts[1]?.toLowerCase() === 'desc' || field.startsWith('-')
const cleanField = field.startsWith('-') ? field.slice(1) : field
list = [...list].sort((a, b) => {
const valA = a[cleanField]
const valB = b[cleanField]
if (valA === undefined || valA === null) return isDesc ? 1 : -1
if (valB === undefined || valB === null) return isDesc ? -1 : 1
if (typeof valA === 'string' && typeof valB === 'string') {
return isDesc ? valB.localeCompare(valA) : valA.localeCompare(valB)
}
return isDesc ? (valB > valA ? 1 : -1) : (valA > valB ? 1 : -1)
})
}
}
const flatList = []
if (container.mode === 'group') { if (container.mode === 'group') {
let filteredList = list
if (filter) {
if (typeof filter === 'function') {
filteredList = list.filter(filter)
} else if (typeof filter === 'string') {
const q = filter.trim().toLowerCase()
if (q) {
filteredList = list.filter(item => {
const label = String(item[container.labelfield] || '').toLowerCase()
const summary = String(item[container.summaryfield] || '').toLowerCase()
return label.includes(q) || summary.includes(q)
})
}
}
}
const itemMap = {} const itemMap = {}
filteredList.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item)); list.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
(container.state.groups || []).forEach(group => { (container.state.groups || []).forEach(group => {
const items = itemMap[group[container.groupidfield]]
const groupLabel = String(group[container.grouplabelfield] || '').toLowerCase()
const groupSummary = String(group[container.groupsummaryfield] || '').toLowerCase()
const q = (typeof filter === 'string') ? filter.trim().toLowerCase() : ''
const groupMatches = q ? (groupLabel.includes(q) || groupSummary.includes(q)) : false
if ((items && items.length > 0) || groupMatches || !filter) {
flatList.push({ type: 'group', ...group }) flatList.push({ type: 'group', ...group })
const items = itemMap[group[container.groupidfield]]
if (items) items.forEach(item => flatList.push({ type: 'item', ...item })) if (items) items.forEach(item => flatList.push({ type: 'item', ...item }))
}
}) })
} else if (container.mode === 'tree') { } else if (container.mode === 'tree') {
const itemMap = {}
list.forEach(item => { itemMap[item[container.idfield]] = item })
const matches = new Set()
list.forEach(item => {
let match = false
if (!filter) {
match = true
} else if (typeof filter === 'function') {
match = filter(item)
} else if (typeof filter === 'string') {
const q = filter.trim().toLowerCase()
if (!q) {
match = true
} else {
const label = String(item[container.labelfield] || '').toLowerCase()
const summary = String(item[container.summaryfield] || '').toLowerCase()
match = label.includes(q) || summary.includes(q)
}
}
if (match) {
matches.add(item[container.idfield])
}
})
const keep = new Set(matches)
if (filter) {
matches.forEach(id => {
let curr = itemMap[id]
while (curr) {
const parentId = curr[container.parentfield]
if (!parentId) break
if (keep.has(parentId)) break
keep.add(parentId)
curr = itemMap[parentId]
}
})
}
const childrenMap = {} const childrenMap = {}
list.forEach(item => { list.forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
if (!filter || keep.has(item[container.idfield])) {
const parentId = item[container.parentfield] || ''
;(childrenMap[parentId] ??= []).push(item)
}
})
const traverse = (items, level, parents) => items.forEach(item => { const traverse = (items, level, parents) => items.forEach(item => {
const id = item[container.idfield] const id = item[container.idfield], hasChildren = !!childrenMap[id]?.length
const hasChildren = !!childrenMap[id]?.length
const isCollapsed = container.collapsed[id] const isCollapsed = container.collapsed[id]
flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents }) flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]) if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id])
}) })
traverse(childrenMap[''] || [], 0, []) traverse(childrenMap[''] || [], 0, [])
} else { } else list.forEach(item => flatList.push({ type: 'item', ...item }))
let filteredList = list
if (filter) {
if (typeof filter === 'function') {
filteredList = list.filter(filter)
} else if (typeof filter === 'string') {
const q = filter.trim().toLowerCase()
if (q) {
filteredList = list.filter(item => {
const label = String(item[container.labelfield] || '').toLowerCase()
const summary = String(item[container.summaryfield] || '').toLowerCase()
return label.includes(q) || summary.includes(q)
})
}
}
}
filteredList.forEach(item => flatList.push({ type: 'item', ...item }))
}
container.state.flatList = flatList container.state.flatList = flatList
} }
container.state.__watch('list', updateFlatList) container.state.__watch('list', updateFlatList)
container.state.__watch('filter', updateFlatList)
container.state.__watch('order', updateFlatList)
const vs = container.fast ? globalThis.VirtualScroll() : null const vs = container.fast ? globalThis.VirtualScroll() : null
let refreshing = false let refreshing = false
@ -196,30 +84,6 @@ globalThis.Component.register('List', container => {
} }
container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } } container.toggleCollapse = (item) => { if (container.collapsible && item._hasChildren) { container.collapsed[item[container.idfield]] = !container.collapsed[item[container.idfield]]; updateFlatList(); } }
Object.defineProperty(container, 'filter', {
get: () => container.state.filter,
set: v => { container.state.filter = v; }
})
Object.defineProperty(container, 'order', {
get: () => container.state.order,
set: v => { container.state.order = v; }
})
const observer = new MutationObserver(() => {
const filterAttr = container.getAttribute('filter')
if (typeof container.state.filter !== 'function' && filterAttr !== container.state.filter) {
container.state.filter = filterAttr || undefined
}
const orderAttr = container.getAttribute('order')
if (typeof container.state.order !== 'function' && orderAttr !== container.state.order) {
container.state.order = orderAttr || undefined
}
})
observer.observe(container, { attributes: true, attributeFilter: ['filter', 'order'] })
container.state.filter = container.getAttribute('filter') || undefined
container.state.order = container.getAttribute('order') || undefined
updateFlatList() updateFlatList()
}, globalThis.Util.makeDom(/*html*/` }, globalThis.Util.makeDom(/*html*/`
<div class="list-group overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none"> <div class="list-group overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">

View File

@ -19,13 +19,13 @@ globalThis.Component.register('Nav', container => {
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''"> <div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''">
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<template $if="this.state?.brand?.image"> <template $if="this.state?.brand?.image">
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'mx-1'}" style="height:30px;width:auto;max-width:300px"> <img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
</template> </template>
<template $if="this.state?.brand?.icon"> <template $if="this.state?.brand?.icon">
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'mx-1'}"></i> <i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'me-2'}"></i>
</template> </template>
<template $if="this.state?.brand?.label"> <template $if="this.state?.brand?.label">
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'mx-1'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span> <span $class="\${this.vertical ? 'mb-2 fw-bold' : 'me-2'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
</template> </template>
</div> </div>
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''"> <div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">

View File

@ -0,0 +1,66 @@
# Instructions
- Following Playwright test failed.
- Explain why, be concise, respect Playwright best practices.
- Provide a snippet of code with the fix, if possible.
# Test info
- Name: atomic_check.spec.js >> Capability demo page atomic tests verification
- Location: test/atomic_check.spec.js:3:1
# Error details
```
Error: expect(received).toContain(expected) // indexOf
Expected value: "Synchronous State Ready"
Received array: []
```
# Page snapshot
```yaml
- generic [ref=e2]:
- navigation [ref=e4]
- generic [ref=e7]:
- heading [level=2]
- button "切换主题" [ref=e9] [cursor=pointer]: 切换主题
```
# Test source
```ts
1 | import { test, expect } from '@playwright/test';
2 |
3 | test('Capability demo page atomic tests verification', async ({ page }) => {
4 | page.on('console', msg => console.log('BROWSER:', msg.text()));
5 |
6 | await page.goto('/test/capability.html');
7 | await page.waitForTimeout(3000);
8 |
9 | const testResults = await page.evaluate(() => {
10 | const getTexts = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.textContent.trim());
11 | const getColors = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.style.color);
12 |
13 | return {
14 | textBinding: getTexts('p.fs-4'),
15 | textColor: getColors('p.fs-4'),
16 | ifTrue: document.querySelector('.alert-success')?.textContent.trim(),
17 | eachItems: getTexts('.card-body .list-group-item'),
18 | nestedEachInIf: getTexts('.badge.bg-secondary'),
19 | nestedIfInEach: getTexts('.card-body .gap-2 .p-2 span')
20 | };
21 | });
22 |
23 | console.log('Atomic Test Results:', JSON.stringify(testResults, null, 2));
24 |
> 25 | expect(testResults.textBinding).toContain('Synchronous State Ready');
| ^ Error: expect(received).toContain(expected) // indexOf
26 | expect(testResults.ifTrue).toBe('已激活 ($if="true")');
27 | expect(testResults.eachItems).toHaveLength(3);
28 | expect(testResults.nestedEachInIf).toHaveLength(3);
29 | expect(testResults.nestedIfInEach).toHaveLength(5); // Members 1-5
30 | });
31 |
```

76
test/all.spec.js Normal file
View File

@ -0,0 +1,76 @@
import { test, expect } from '@playwright/test';
test('base project comprehensive tests and scrolling benchmarks', async ({ page }) => {
test.setTimeout(60000);
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
page.on('pageerror', err => console.log('BROWSER EXCEPTION:', err.message, err.stack));
await page.goto('http://127.0.0.1:8082/test/index.html');
// Wait for testStatus to be set (includes basic unit tests and scrolling refresh test)
await page.waitForFunction(() => window.testStatus !== undefined, { timeout: 60000 });
const status = await page.evaluate(() => window.testStatus);
expect(status).toBe('passed');
// Simulate real scrolling and check dynamic rendering for FastList
console.log('Simulating real scroll on FastList...');
const scrollInfo = await page.evaluate(async () => {
window.switchTab?.('ll');
await new Promise(r => setTimeout(r, 200)); // wait for render
const el = document.getElementById('ll');
const results = [];
const getRenderedCount = () => el.querySelectorAll('.border-primary').length;
results.push({
id: 'initial',
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
renderedCount: getRenderedCount(),
renderedListLen: el.state.renderedList?.length
});
// Scroll to middle
el.scrollTop = 50000;
el.refresh?.();
await new Promise(r => setTimeout(r, 500));
const midCount = getRenderedCount();
const midHtml = el.innerHTML.length;
results.push({
id: 'deep-middle',
scrollTop: el.scrollTop,
renderedCount: midCount,
htmlLen: midHtml,
prevHeight: el.state.prevHeight,
postHeight: el.state.postHeight,
renderedListLen: el.state._renderedList?.length
});
// Scroll to end
el.scrollTop = el.scrollHeight;
el.refresh?.();
await new Promise(r => setTimeout(r, 500));
results.push({
id: 'end',
scrollTop: el.scrollTop,
renderedCount: getRenderedCount(),
scrollHeight: el.scrollHeight,
prevHeight: el.state.prevHeight,
postHeight: el.state.postHeight
});
return results;
});
console.log('Scroll Details:', JSON.stringify(scrollInfo, null, 2));
expect(scrollInfo[0].renderedCount).toBeGreaterThan(0);
expect(scrollInfo[1].scrollTop).toBeGreaterThan(0);
const bench = await page.evaluate(() => window.benchResults);
if (bench) {
Object.entries(bench).forEach(([name, time]) => {
console.log(`BENCHMARK [${name}]: ${time.toFixed(2)}ms`);
});
}
});

View File

@ -1,36 +0,0 @@
import { test, expect } from '@playwright/test';
test('API keeps declarative preset request fields', async ({ page }) => {
let capturedBody = null;
await page.route('**/echo', async route => {
capturedBody = route.request().postData();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true })
});
});
await page.goto('/test/api_test.html');
await page.waitForFunction(() => typeof globalThis.presetApi?.do === 'function' && !!globalThis.presetApi?.request);
const requestInfo = await page.evaluate(() => ({
url: presetApi.request?.url,
method: presetApi.request?.method,
secret: presetApi.request?.data?.secret
}));
expect(requestInfo).toEqual({
url: '/echo',
method: 'POST',
secret: 'playwright-secret'
});
const ok = await page.evaluate(async () => {
const resp = await presetApi.do({ noui: true });
return resp.ok;
});
expect(ok).toBe(true);
expect(capturedBody).toBe(JSON.stringify({ secret: 'playwright-secret' }));
});

View File

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>API Declarative Test</title>
<script src="./lib/state.js"></script>
<script src="./lib/bootstrap.js"></script>
<script src="./lib/base.js"></script>
<script>
LocalStorage.secret = 'playwright-secret';
</script>
</head>
<body>
<API id="presetApi"
$.request.url="'/echo'"
$.request.method="'POST'"
$.request.data.secret="LocalStorage.secret || ''">
</API>
</body>
</html>

32
test/atomic_check.spec.js Normal file
View File

@ -0,0 +1,32 @@
import { test, expect } from '@playwright/test';
test('Capability demo page atomic tests verification', async ({ page }) => {
page.on('console', msg => console.log('BROWSER:', msg.text()));
await page.goto('http://localhost:5173/test/capability.html');
await page.waitForTimeout(3000);
const testResults = await page.evaluate(() => {
const getTexts = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.textContent.trim());
const getColors = (sel) => Array.from(document.querySelectorAll(sel)).map(el => el.style.color);
return {
textBinding: getTexts('p[$text="DemoState.testTitle"]'),
textColor: getColors('p[$text="DemoState.testTitle"]'),
ifTrue: document.querySelector('.alert-success')?.textContent.trim(),
ifFalse: document.querySelector('.alert-danger')?.textContent.trim(),
eachItems: getTexts('li.list-group-item'),
nestedEachInIf: getTexts('.badge.bg-primary'),
nestedIfInEach: getTexts('.bg-white')
};
});
console.log('Atomic Test Results:', JSON.stringify(testResults, null, 2));
expect(testResults.textBinding).toContain('Framework Is Active');
expect(testResults.ifTrue).toBe('显示的内容 (True)');
expect(testResults.ifFalse).toBeUndefined();
expect(testResults.eachItems).toHaveLength(3);
expect(testResults.nestedEachInIf).toHaveLength(3);
expect(testResults.nestedIfInEach).toHaveLength(2); // Item A and C are visible
});

162
test/base.test.js Normal file
View File

@ -0,0 +1,162 @@
import { HTTP, UI, State } from '@apigo.cc/base';
export async function runTests() {
console.log('Starting comprehensive Base.js tests...');
// 1. HTTP Test
console.log('Testing HTTP (local check)...');
if (typeof HTTP.request !== 'function') throw new Error('HTTP.request missing');
// 2. State Test
console.log('Testing State...');
State.exitBlocks = 1;
if (State.exitBlocks !== 1) throw new Error('State update failed');
State.exitBlocks = 0;
// 3. UI Namespace Test
console.log('Testing UI...');
if (typeof UI.alert !== 'function') throw new Error('UI.alert missing');
if (typeof UI.toast !== 'function') throw new Error('UI.toast missing');
// 4. API Component Test
console.log('Testing API Component...');
const api = document.createElement('API');
document.body.appendChild(api);
await new Promise(r => setTimeout(r, 50));
api.request.url = '../package.json';
const apiResp = await api.do();
if (!apiResp.ok) throw new Error('API component request failed');
api.remove();
console.log('Testing API declarative preset request fields...');
LocalStorage.secret = 'api-secret-token';
const presetApi = document.createElement('API');
presetApi.setAttribute('$.request.url', "'/echo'");
presetApi.setAttribute('$.request.method', "'POST'");
presetApi.setAttribute('$.request.data.secret', "LocalStorage.secret || ''");
document.body.appendChild(presetApi);
await new Promise(r => setTimeout(r, 50));
const originalFetch = globalThis.fetch;
let capturedBody = null;
globalThis.fetch = async (_url, options = {}) => {
capturedBody = options.body;
return new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
};
try {
const presetResp = await presetApi.do();
if (!presetResp.ok) throw new Error('Declarative API preset request failed');
} finally {
globalThis.fetch = originalFetch;
presetApi.remove();
}
if (capturedBody !== JSON.stringify({ secret: 'api-secret-token' })) {
throw new Error('API declarative preset request.data.secret was not preserved');
}
// 5. AutoForm & TagsInput Test
console.log('Testing AutoForm...');
const { NewState } = await import('@apigo.cc/state');
const form = document.createElement('AutoForm');
document.body.appendChild(form);
await new Promise(r => setTimeout(r, 200));
// Test dynamic schema and visibility
form.data = NewState({ showName: false, name: 'Gemini' });
form.state.schema = [
{ name: 'tags', type: 'TagsInput', label: '标签' },
{ name: 'showName', type: 'switch', label: 'Show Name' },
{ name: 'name', type: 'text', label: 'Name', if: 'this.data.showName' }
];
await new Promise(r => setTimeout(r, 200));
if (!form.querySelector('TagsInput')) throw new Error('TagsInput not rendered');
if (form.querySelector('[name="name"]')) throw new Error('Name field should be hidden');
form.data.showName = true;
await new Promise(r => setTimeout(r, 200));
if (!form.querySelector('[name="name"]')) throw new Error('Name field should be visible');
form.remove();
// 6. New Controls (DatePicker, ColorPicker, IconPicker) Test
console.log('Testing New Controls...');
const controlForm = document.createElement('AutoForm');
document.body.appendChild(controlForm);
await new Promise(r => setTimeout(r, 200));
controlForm.state.schema = [
{ name: 'startDate', type: 'DatePicker', setting: { rangeEnd: 'endDate' } },
{ name: 'endDate', type: 'date' },
{ name: 'color', type: 'ColorPicker' },
{ name: 'icon', type: 'IconPicker' }
];
controlForm.data = { startDate: '2026-05-01', endDate: '2026-05-31', color: '#ff0000', icon: 'gear' };
await new Promise(r => setTimeout(r, 400));
const dp = controlForm.querySelector('DatePicker');
const cp = controlForm.querySelector('ColorPicker');
const ip = controlForm.querySelector('IconPicker');
if (!dp) throw new Error('DatePicker not rendered');
if (!cp) throw new Error('ColorPicker not rendered');
if (!ip) throw new Error('IconPicker not rendered');
// Test DatePicker range sync
dp.updateEnd('2026-06-01');
if (controlForm.data.endDate !== '2026-06-01') throw new Error('DatePicker rangeEnd sync failed');
// Test ColorPicker
cp.updateValue('#00ff00');
if (controlForm.data.color !== '#00ff00') throw new Error('ColorPicker sync failed');
// Test IconPicker
ip.selectIcon('star');
if (controlForm.data.icon !== 'star') throw new Error('IconPicker sync failed');
controlForm.remove();
// 7. List Components Basic Verification
console.log('Verifying List Components...');
const { Component } = await import('@apigo.cc/state');
console.log('FastList exists:', Component.exists('FastList'));
const listIds = ['ll', 'gl', 'tt', 'ct'];
for (const id of listIds) {
window.switchTab?.(id);
await new Promise(r => setTimeout(r, 100)); // wait for render
const el = document.getElementById(id);
if (!el) throw new Error(`Component #${id} not found`);
console.log(`Component #${id} tagName:`, el.tagName, 'has refresh:', !!el.refresh, 'setupFunc exists:', !!Component.getSetupFunction(el.tagName));
if (!el.state.list || el.state.list.length === 0) throw new Error(`Component #${id} list data not bound`);
}
// 7. Benchmarks
console.log('Recording list benchmarks...');
window.benchResults = {};
const measure = async (id, name) => {
window.switchTab?.(id);
await new Promise(r => setTimeout(r, 100)); // wait for render
const el = document.getElementById(id);
const start = performance.now();
console.log(`[MEASURE] ${name} setting scrollTop to 5000`);
el.scrollTop = 5000;
console.log(`[MEASURE] ${name} calling refresh`);
el.refresh?.();
console.log(`[MEASURE] ${name} refresh called, waiting 50ms`);
await new Promise(r => setTimeout(r, 50));
console.log(`[MEASURE] ${name} wait done`);
const time = performance.now() - start;
window.benchResults[name] = time;
console.log(`BENCHMARK: ${name} scroll & refresh: ${time.toFixed(2)}ms`);
console.log(`DEBUG [${id}]: _renderedList len: ${el.state._renderedList?.length}, _flatList len: ${el.state._flatList?.length}`);
};
await measure('ll', 'FastList');
await measure('gl', 'FastGroupedList');
await measure('tt', 'FastTree');
await measure('ct', 'CollapseTree');
console.log('All Base.js unit tests completed.');
}

View File

@ -1,14 +0,0 @@
import { test, expect } from '@playwright/test';
test('capability demo switches sections through Nav', async ({ page }) => {
await page.goto('/test/capability.html');
await expect(page.locator('#mainNav')).toBeVisible();
await expect(page.getByRole('heading', { name: '项目概览' })).toBeVisible();
await page.getByRole('button', { name: '表单与控件' }).click();
await expect(page.locator('#demoForm')).toBeVisible();
await page.getByRole('button', { name: '增强列表' }).click();
await expect(page.locator('#demoList')).toBeVisible();
});

29
test/deep_dump.spec.js Normal file
View File

@ -0,0 +1,29 @@
import { test, expect } from '@playwright/test';
test('Capability demo page deep DOM dump', async ({ page }) => {
page.on('console', msg => console.log('BROWSER:', msg.text()));
await page.goto('http://localhost:5173/test/capability.html');
await page.waitForTimeout(3000);
const dump = await page.evaluate(() => {
const nav = document.getElementById('mainNav');
const form = document.getElementById('demoForm');
return {
nav: {
tagName: nav?.tagName,
childCount: nav?.children.length,
innerHTML: nav?.innerHTML
},
form: {
tagName: form?.tagName,
childCount: form?.children.length,
innerHTML: form?.innerHTML
}
};
});
console.log('Deep DOM Dump:', JSON.stringify(dump, null, 2));
expect(dump.nav.childCount).toBeGreaterThan(0);
});

View File

@ -1,21 +0,0 @@
import { test, expect } from '@playwright/test';
test('AutoForm fixtures render in supported layouts', async ({ page }) => {
await page.goto('/test/form_test.html');
await expect(page.locator('#formV')).toBeVisible();
await expect(page.locator('#formH')).toBeVisible();
await expect(page.locator('#formFH')).toBeVisible();
await expect(page.locator('#formSearch')).toBeVisible();
await expect(page.locator('#formCompact')).toBeVisible();
await expect(page.locator('#formV input[name="t"]')).toHaveValue('Apigo');
const horizontalDisplay = await page.locator('#formH form').evaluate(node => getComputedStyle(node).display);
expect(horizontalDisplay).toBe('grid');
await expect(page.locator('#formV DatePicker')).toHaveCount(2);
await expect(page.locator('#formV ColorPicker')).toBeVisible();
await expect(page.locator('#formV TagsInput')).toBeVisible();
await expect(page.locator('#formCompact button[type="submit"]')).toHaveCount(0);
});

116
test/index.html Normal file
View File

@ -0,0 +1,116 @@
<!DOCTYPE html>
<html>
<head>
<title>Base.js Modular & Performance Tests</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script type="importmap">
{
"imports": {
"@apigo.cc/state": "../../state/src/index.js",
"@apigo.cc/base": "../src/index.js"
}
}
</script>
</head>
<body class="d-flex flex-column vh-100">
<script>
window.addEventListener('error', e => console.log('GLOBAL ERROR:', e.message, e.filename, e.lineno, e.colno, e.error));
window.addEventListener('unhandledrejection', e => console.log('UNHANDLED REJECTION:', e.reason));
</script>
<div id="results" class="p-2 bg-light border-bottom">Running tests...</div>
<script>
const list_data = []
for (let i = 0; i < 10000; i++) {
list_data.push({ id: i, label: `item ${i}`, index: i, group: i % 1000, summary: `item ${i} summary`, parent: Math.floor(i / 10) })
}
const group_list = []
for (let i = 0; i < 1000; i++) {
group_list.push({ id: i, label: `group ${i}`, summary: `group ${i} summary` })
}
</script>
<script>
function switchTab(tabId) {
document.querySelectorAll('.list-container').forEach(el => el.style.display = 'none');
const target = document.getElementById('container-' + tabId);
if (target) target.style.display = 'flex';
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
const btn = document.getElementById('btn-' + tabId);
if (btn) btn.classList.add('active');
window.activeTab = tabId;
}
</script>
<div class="p-2 border-bottom d-flex gap-2">
<button id="btn-ll" class="tab-btn btn btn-sm btn-outline-primary active" onclick="switchTab('ll')">Fast List</button>
<button id="btn-gl" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('gl')">Fast Grouped</button>
<button id="btn-tt" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('tt')">Fast Tree</button>
<button id="btn-ct" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('ct')">Normal Tree</button>
<button id="btn-form" class="tab-btn btn btn-sm btn-outline-primary" onclick="switchTab('form')">AutoForm Controls</button>
</div>
<div class="d-flex flex-fill p-2 overflow-hidden">
<div id="container-ll" class="list-container flex-fill d-flex flex-column overflow-hidden">
<h5>Fast List (Variable Height)</h5>
<List fast id="ll" auto-select class="flex-fill d-flex flex-column gap-3 bg-body-secondary rounded" $.state.list="list_data" $onitemclick="console.log(index, item)">
<template slot="item">
<div class="d-flex justify-content-center align-items-center border border-primary rounded" $text="item.label" $.style.height="${(item.index%10)*5+40}px"></div>
</template>
</List>
</div>
<div id="container-gl" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
<h5>Fast Grouped List</h5>
<List fast mode="group" id="gl" auto-select auto-select-group class="flex-fill d-flex flex-column border border-info rounded" $.state.groups="group_list" $.state.list="list_data"
$ongroupclick="console.log(index, item)">
</List>
</div>
<div id="container-tt" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
<h5>Fast Tree List</h5>
<List fast mode="tree" id="tt" auto-select class="flex-fill d-flex flex-column border border-info rounded" $.state.list="list_data" $onitemclick="console.log(index, item)"></List>
</div>
<div id="container-ct" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
<h5>Normal Tree List (Collapsible)</h5>
<List mode="tree" collapsible id="ct" auto-select class="flex-fill d-flex flex-column border border-info rounded" $.state.list="list_data.slice(0, 1200)" $onitemclick="console.log(index, item)">
</List>
</div>
<div id="container-form" class="list-container flex-fill flex-column overflow-hidden" style="display: none;">
<h5>AutoForm Controls</h5>
<AutoForm id="test-form" class="border p-3 rounded bg-light" $onsubmit="UI.toast('Form Submitted: ' + JSON.stringify(event.detail))"></AutoForm>
<script>
setTimeout(() => {
const form = document.getElementById('test-form');
if (form) {
form.state.schema = [
{ name: 'startDate', label: 'Date Range', type: 'DatePicker', setting: { rangeEnd: 'endDate' } },
{ name: 'endDate', label: 'End Date (Shadow)', type: 'date' },
{ name: 'themeColor', label: 'Theme Color', type: 'ColorPicker' },
{ name: 'appIcon', label: 'App Icon', type: 'IconPicker' }
];
form.data = { startDate: '2026-05-01', endDate: '2026-05-31', themeColor: '#ff0000', appIcon: 'gear' };
}
}, 600);
</script>
</div>
</div>
<script type="module">
import { runTests } from './base.test.js';
async function runAll() {
const results = document.getElementById('results');
try {
// Wait for components to initialize
await new Promise(r => setTimeout(r, 500));
await runTests();
results.innerHTML = '<h1 style="color: green; font-size: 1.2rem">All Tests Passed 🎉</h1>';
window.testStatus = 'passed';
} catch (e) {
console.error(e);
results.innerHTML = '<h1 style="color: red; font-size: 1.2rem">Tests Failed: ' + e.message + '</h1>';
window.testStatus = 'failed';
}
}
runAll();
</script>
</body>
</html>

View File

@ -30,24 +30,11 @@
} }
options.body = data; options.body = data;
} }
for (const name of HTTP.keepHeaders) {
if (HTTP._keepHeadersCache[name] && !headers[name]) headers[name] = HTTP._keepHeadersCache[name];
}
if (Object.keys(headers).length) options.headers = headers; if (Object.keys(headers).length) options.headers = headers;
const response = { error: null, ok: null, status: 0, headers: {}, responseType: "", result: null }; const response = { error: null, ok: null, status: 0, headers: {}, responseType: "", result: null };
try { try {
const resp = await fetch(url, options); const resp = await fetch(url, options);
Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) }); Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) });
for (const name of HTTP.keepHeaders) {
const val = resp.headers.get(name);
if (val) {
HTTP._keepHeadersCache[name] = val;
try {
localStorage.setItem("_http_keep_" + name, val);
} catch (e) {
}
}
}
if (!responseType) { if (!responseType) {
const contentType = resp.headers.get("Content-Type") || ""; const contentType = resp.headers.get("Content-Type") || "";
if (contentType.includes("application/json")) responseType = "json"; if (contentType.includes("application/json")) responseType = "json";
@ -64,15 +51,6 @@
return response; return response;
} }
}; };
HTTP.keepHeaders = ["Session-Id", "Device-Id"];
HTTP._keepHeadersCache = {};
for (const name of HTTP.keepHeaders) {
try {
const val = localStorage.getItem("_http_keep_" + name);
if (val !== null) HTTP._keepHeadersCache[name] = val;
} catch (e) {
}
}
globalThis.HTTP = HTTP; globalThis.HTTP = HTTP;
const UI = {}; const UI = {};
globalThis.Component.register("Toast", (container) => { globalThis.Component.register("Toast", (container) => {
@ -149,14 +127,14 @@
<option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option> <option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option>
</select> </select>
<div $if="['checkbox', 'radio'].includes(item.type)" class="d-flex align-items-center flex-wrap gap-3 h-100" style="padding: 0 0.75rem; min-height: calc(2.25rem + 2px);"> <div $if="['checkbox', 'radio'].includes(item.type)" class="d-flex align-items-center flex-wrap gap-3 h-100">
<label $each="item.options || [item.text||item.label||item.name]" as="opt" class="form-check mb-0 d-flex align-items-center" style="padding-left:0; cursor:pointer;"> <label $each="item.options || [item.text||item.label||item.name]" as="opt" class="form-check mb-0 d-flex align-items-center" style="padding-left:0; cursor:pointer;">
<input $name="item.name" class="form-check-input m-0 me-2" style="float:none;" $type="item.type" $.="item.setting || {}" $value="item.options ? (opt.value !== undefined ? opt.value : opt) : 'on'" $bind="this.data[item.name]"> <input $name="item.name" class="form-check-input m-0 me-2" style="float:none;" $type="item.type" $.="item.setting || {}" $value="item.options?opt:'on'" $bind="this.data[item.name]">
<span $if="!this.inline || (item.options && item.options.length > 0)" $text="opt.label || opt" class="form-check-label"></span> <span $if="!this.inline || (item.options && item.options.length > 0)" $text="opt.label || opt" class="form-check-label"></span>
</label> </label>
</div> </div>
<div $if="item.type === 'switch'" class="form-check form-switch fs-5 d-flex align-items-center m-0" style="padding: 0 0.75rem; min-height: calc(2.25rem + 2px); display:flex !important;"> <div $if="item.type === 'switch'" class="form-check form-switch fs-5 d-flex align-items-center m-0" style="padding-left:0; min-height:0; display:flex !important;">
<input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" type="checkbox" $bind="this.data[item.name]"> <input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" type="checkbox" $bind="this.data[item.name]">
</div> </div>
@ -399,31 +377,9 @@
} }
globalThis.MouseMover = MouseMover; globalThis.MouseMover = MouseMover;
const APIComponent = globalThis.Component.register("API", (container) => { const APIComponent = globalThis.Component.register("API", (container) => {
const presetRequest = container.request && typeof container.request === "object" ? container.request : {}; container.request = globalThis.NewState({ url: "", method: "GET", headers: {}, data: null, timeout: 1e4, responseType: "" });
const presetResponse = container.response && typeof container.response === "object" ? container.response : {}; container.response = globalThis.NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: "", result: null });
const presetResult = container.result && typeof container.result === "object" ? container.result : {}; container.result = globalThis.NewState();
container.request = globalThis.NewState({
url: "",
method: "GET",
headers: {},
data: null,
timeout: 1e4,
responseType: "",
...presetRequest,
headers: { ...presetRequest.headers || {} }
});
container.response = globalThis.NewState({
loading: false,
ok: null,
status: null,
error: null,
headers: {},
responseType: "",
result: null,
...presetResponse,
headers: { ...presetResponse.headers || {} }
});
container.result = globalThis.NewState(presetResult);
container.do = (opt = {}) => { container.do = (opt = {}) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const req = { ...container.request, ...opt }; const req = { ...container.request, ...opt };
@ -557,7 +513,7 @@
} }
})}' $text="item"></button> })}' $text="item"></button>
</template> </template>
<input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onchange="event.stopPropagation()" $oninput="event.stopPropagation()" $onkeydown='${globalThis.Util.getFunctionBody(function(event) { <input type="text" class="border-0 shadow-none py-0 px-2 flex-grow-1 bg-transparent" placeholder="{#new tag name#}" style="min-width:100px;width:0;outline:none" $onkeydown='${globalThis.Util.getFunctionBody(function(event) {
if (event.isComposing) return; if (event.isComposing) return;
if (["Enter", ",", " "].includes(event.key)) { if (["Enter", ",", " "].includes(event.key)) {
event.preventDefault(); event.preventDefault();
@ -762,142 +718,37 @@
container.collapsed = globalThis.NewState({}); container.collapsed = globalThis.NewState({});
container.state.renderedList = []; container.state.renderedList = [];
const updateFlatList = () => { const updateFlatList = () => {
var _a;
globalThis.Util.updateDefaults(container, defaultSets); globalThis.Util.updateDefaults(container, defaultSets);
let list = container.state.list || []; const list = container.state.list || [], flatList = [];
const filter = container.state.filter;
const order = container.state.order;
if (order) {
if (typeof order === "function") {
list = [...list].sort(order);
} else if (typeof order === "string") {
const parts = order.trim().split(/\s+/);
const field = parts[0];
const isDesc = ((_a = parts[1]) == null ? void 0 : _a.toLowerCase()) === "desc" || field.startsWith("-");
const cleanField = field.startsWith("-") ? field.slice(1) : field;
list = [...list].sort((a, b) => {
const valA = a[cleanField];
const valB = b[cleanField];
if (valA === void 0 || valA === null) return isDesc ? 1 : -1;
if (valB === void 0 || valB === null) return isDesc ? -1 : 1;
if (typeof valA === "string" && typeof valB === "string") {
return isDesc ? valB.localeCompare(valA) : valA.localeCompare(valB);
}
return isDesc ? valB > valA ? 1 : -1 : valA > valB ? 1 : -1;
});
}
}
const flatList = [];
if (container.mode === "group") { if (container.mode === "group") {
let filteredList = list;
if (filter) {
if (typeof filter === "function") {
filteredList = list.filter(filter);
} else if (typeof filter === "string") {
const q = filter.trim().toLowerCase();
if (q) {
filteredList = list.filter((item) => {
const label = String(item[container.labelfield] || "").toLowerCase();
const summary = String(item[container.summaryfield] || "").toLowerCase();
return label.includes(q) || summary.includes(q);
});
}
}
}
const itemMap = {}; const itemMap = {};
filteredList.forEach((item) => { list.forEach((item) => {
var _a2; var _a;
return (itemMap[_a2 = item[container.groupfield]] ?? (itemMap[_a2] = [])).push(item); return (itemMap[_a = item[container.groupfield]] ?? (itemMap[_a] = [])).push(item);
}); });
(container.state.groups || []).forEach((group) => { (container.state.groups || []).forEach((group) => {
const items = itemMap[group[container.groupidfield]];
const groupLabel = String(group[container.grouplabelfield] || "").toLowerCase();
const groupSummary = String(group[container.groupsummaryfield] || "").toLowerCase();
const q = typeof filter === "string" ? filter.trim().toLowerCase() : "";
const groupMatches = q ? groupLabel.includes(q) || groupSummary.includes(q) : false;
if (items && items.length > 0 || groupMatches || !filter) {
flatList.push({ type: "group", ...group }); flatList.push({ type: "group", ...group });
const items = itemMap[group[container.groupidfield]];
if (items) items.forEach((item) => flatList.push({ type: "item", ...item })); if (items) items.forEach((item) => flatList.push({ type: "item", ...item }));
}
}); });
} else if (container.mode === "tree") { } else if (container.mode === "tree") {
const itemMap = {};
list.forEach((item) => {
itemMap[item[container.idfield]] = item;
});
const matches = /* @__PURE__ */ new Set();
list.forEach((item) => {
let match = false;
if (!filter) {
match = true;
} else if (typeof filter === "function") {
match = filter(item);
} else if (typeof filter === "string") {
const q = filter.trim().toLowerCase();
if (!q) {
match = true;
} else {
const label = String(item[container.labelfield] || "").toLowerCase();
const summary = String(item[container.summaryfield] || "").toLowerCase();
match = label.includes(q) || summary.includes(q);
}
}
if (match) {
matches.add(item[container.idfield]);
}
});
const keep = new Set(matches);
if (filter) {
matches.forEach((id) => {
let curr = itemMap[id];
while (curr) {
const parentId = curr[container.parentfield];
if (!parentId) break;
if (keep.has(parentId)) break;
keep.add(parentId);
curr = itemMap[parentId];
}
});
}
const childrenMap = {}; const childrenMap = {};
list.forEach((item) => { list.forEach((item) => {
if (!filter || keep.has(item[container.idfield])) { var _a;
const parentId = item[container.parentfield] || ""; return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
(childrenMap[parentId] ?? (childrenMap[parentId] = [])).push(item);
}
}); });
const traverse = (items, level, parents) => items.forEach((item) => { const traverse = (items, level, parents) => items.forEach((item) => {
var _a2; var _a;
const id = item[container.idfield]; const id = item[container.idfield], hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.length);
const hasChildren = !!((_a2 = childrenMap[id]) == null ? void 0 : _a2.length);
const isCollapsed = container.collapsed[id]; const isCollapsed = container.collapsed[id];
flatList.push({ type: "item", ...item, _level: level, _hasChildren: hasChildren, _parents: parents }); flatList.push({ type: "item", ...item, _level: level, _hasChildren: hasChildren, _parents: parents });
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]); if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]);
}); });
traverse(childrenMap[""] || [], 0, []); traverse(childrenMap[""] || [], 0, []);
} else { } else list.forEach((item) => flatList.push({ type: "item", ...item }));
let filteredList = list;
if (filter) {
if (typeof filter === "function") {
filteredList = list.filter(filter);
} else if (typeof filter === "string") {
const q = filter.trim().toLowerCase();
if (q) {
filteredList = list.filter((item) => {
const label = String(item[container.labelfield] || "").toLowerCase();
const summary = String(item[container.summaryfield] || "").toLowerCase();
return label.includes(q) || summary.includes(q);
});
}
}
}
filteredList.forEach((item) => flatList.push({ type: "item", ...item }));
}
container.state.flatList = flatList; container.state.flatList = flatList;
}; };
container.state.__watch("list", updateFlatList); container.state.__watch("list", updateFlatList);
container.state.__watch("filter", updateFlatList);
container.state.__watch("order", updateFlatList);
const vs = container.fast ? globalThis.VirtualScroll() : null; const vs = container.fast ? globalThis.VirtualScroll() : null;
let refreshing = false; let refreshing = false;
container.refresh = () => { container.refresh = () => {
@ -945,31 +796,6 @@
updateFlatList(); updateFlatList();
} }
}; };
Object.defineProperty(container, "filter", {
get: () => container.state.filter,
set: (v) => {
container.state.filter = v;
}
});
Object.defineProperty(container, "order", {
get: () => container.state.order,
set: (v) => {
container.state.order = v;
}
});
const observer = new MutationObserver(() => {
const filterAttr = container.getAttribute("filter");
if (typeof container.state.filter !== "function" && filterAttr !== container.state.filter) {
container.state.filter = filterAttr || void 0;
}
const orderAttr = container.getAttribute("order");
if (typeof container.state.order !== "function" && orderAttr !== container.state.order) {
container.state.order = orderAttr || void 0;
}
});
observer.observe(container, { attributes: true, attributeFilter: ["filter", "order"] });
container.state.filter = container.getAttribute("filter") || void 0;
container.state.order = container.getAttribute("order") || void 0;
updateFlatList(); updateFlatList();
}, globalThis.Util.makeDom( }, globalThis.Util.makeDom(
/*html*/ /*html*/
@ -1012,104 +838,55 @@
)); ));
globalThis.Component.register("Nav", (container) => { globalThis.Component.register("Nav", (container) => {
container.vertical = container.hasAttribute("vertical"); container.vertical = container.hasAttribute("vertical");
container.state.openName = container.state.openName || null;
container.click = (item, noselect) => { container.click = (item, noselect) => {
if (!item.noselect && !noselect) globalThis.Hash.nav = item.name; if (!item.noselect && !noselect) globalThis.Hash.nav = item.name;
container.dispatchEvent(new CustomEvent("nav", { detail: { item }, bubbles: false })); container.dispatchEvent(new CustomEvent("nav", { detail: { item }, bubbles: false }));
}; };
container.clickSubitem = (item) => {
container.dispatchEvent(new CustomEvent("nav", { detail: { item }, bubbles: false }));
};
container.toggleGroup = (item) => {
container.state.openName = container.state.openName === item.name ? null : item.name;
container.dispatchEvent(new CustomEvent("nav", { detail: { item, open: container.state.openName === item.name }, bubbles: false }));
};
}, globalThis.Util.makeDom( }, globalThis.Util.makeDom(
/*html*/ /*html*/
` `
<div $class="\${this.vertical ? 'd-flex flex-column border-end h-100 align-self-stretch overflow-visible' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-2 \${this.vertical ? 'py-3' : 'py-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}" $style="this.vertical ? 'min-height:0;' : ''"> <div $class="\${this.vertical ? 'd-flex flex-column border-end h-100' : 'navbar navbar-expand border-bottom'} bg-body-secondary px-3 \${this.vertical ? 'py-3' : 'pb-0'} align-items-center \${this.vertical ? 'align-items-start' : ''}">
<div class="d-flex align-items-center gap-2">
<template $if="this.state?.brand?.image"> <template $if="this.state?.brand?.image">
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'mx-1'}" style="height:30px;width:auto;max-width:300px"> <img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
</template> </template>
<template $if="this.state?.brand?.icon"> <template $if="this.state?.brand?.icon">
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'mx-1'}"></i> <i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
</template> </template>
<template $if="this.state?.brand?.label"> <template $if="this.state?.brand?.label">
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'mx-1'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span> <span $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
</template> </template>
</div> <div $class="\${this.vertical ? 'w-100' : 'ms-2'}"></div>
<div $class="d-flex \${this.vertical ? 'w-100 flex-fill mt-2 flex-column' : 'ms-2 align-items-center flex-fill'}" $style="this.vertical ? 'min-height:0; overflow-y:auto; overflow-x:visible;' : ''">
<template $each="this.state?.list || []"> <template $each="this.state?.list || []">
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}" style="transform: translateY(3px);"> <div $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
<template $if="item.type==='label'">
<div $class="\${this.vertical ? 'small text-uppercase text-body-secondary fw-semibold px-2 py-1 mt-2' : 'navbar-text small text-uppercase text-body-secondary fw-semibold px-2'} \${item.class || ''}" $text="item.label"></div>
</template>
<template $if="item.type==='button'"> <template $if="item.type==='button'">
<button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'w-100 text-start py-1 px-2' : ''} \${item.class || ''}" $onclick="this.click(item)"> <button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" $onclick="this.click(item)">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? 'text-truncate' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span> <i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
</button> </button>
</template> </template>
<template $if="item.type==='dropdown'"> <template $if="item.type==='dropdown'">
<template $if="this.vertical">
<div class="w-100">
<button type="button" $class="nav-link w-100 text-start py-1 px-2 d-flex align-items-center justify-content-between \${item.class || ''}" $onclick="this.toggleGroup(item)">
<span class="d-inline-flex align-items-center">
<i $class="bi bi-\${item.icon} me-2"></i><span class="text-truncate" $text="item.label"></span>
</span>
<i $class="bi \${this.state.openName===item.name?'bi-chevron-down':'bi-chevron-right'} small ms-2"></i>
</button>
<template $if="this.state.openName===item.name">
<div class="d-flex flex-column w-100 ps-2 mt-1">
<template $each="item.list" as="subitem">
<template $if="subitem.type==='label'">
<div $class="small text-uppercase text-body-secondary fw-semibold px-2 py-1 \${subitem.class || ''}" $text="subitem.label"></div>
</template>
<template $if="subitem.type==='button'">
<button $class="nav-link w-100 text-start py-1 px-2 \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
</button>
</template>
<template $if="subitem.type==='switch'">
<div $class="d-flex align-items-center px-2 py-2 \${subitem.class || ''}">
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
</div>
</template>
</template>
</div>
</template>
</div>
</template>
<template $if="!this.vertical">
<div class="dropdown"> <div class="dropdown">
<button type="button" $class="nav-link dropdown-toggle \${item.class || ''}" data-bs-toggle="dropdown"> <button $class="nav-link \${Hash.nav===item.name?'active':''} \${this.vertical ? 'text-start' : ''}" data-bs-toggle="dropdown">
<i $class="bi bi-\${item.icon} me-2"></i><span $class="'d-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'" $text="item.label"></span> <i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? '' : '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 \${this.vertical?'position-static':'dropdown-menu-end'} p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
<template $each="item.list" as="subitem"> <template $each="item.list" as="subitem">
<template $if="subitem.type==='label'">
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>
</template>
<template $if="subitem.type==='button'"> <template $if="subitem.type==='button'">
<button $class="dropdown-item \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)"> <button class="nav-link px-0 w-100 text-start" $onclick="this.click(subitem, true)">
<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>
<template $if="subitem.type==='switch'"> <template $if="subitem.type==='switch'">
<div $class="d-flex align-items-center px-3 py-2 \${subitem.class || ''}"> <div class="d-flex align-items-center">
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div> <i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div> <div class="form-switch"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
</div> </div>
</template> </template>
</template> </template>
</div> </div>
</div> </div>
</template> </template>
</template>
</div> </div>
</template> </template>
</div>
</div> </div>
` `
)); ));

20
test/lib/bootstrap.js vendored
View File

@ -2121,7 +2121,7 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
} }
})(); })();
(function(global, factory) { (function(global, factory) {
typeof exports === "object" && typeof module !== "undefined" ? factory(exports, require("@apigo.cc/state")) : typeof define === "function" && define.amd ? define(["exports", "@apigo.cc/state"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.ApigoBootstrap = global.ApigoBootstrap || {})); typeof exports === "object" && typeof module !== "undefined" ? factory(exports, require("@apigo.cc/state")) : typeof define === "function" && define.amd ? define(["exports", "@apigo.cc/state"], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self, factory(global.ApigoBootstrap = {}));
})(this, function(exports2) { })(this, function(exports2) {
"use strict"; "use strict";
var top = "top"; var top = "top";
@ -7233,24 +7233,6 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
border-color: var(--bs-primary) !important; border-color: var(--bs-primary) !important;
} }
/* 导航与下拉菜单 (Nav & Dropdown) */
.nav {
--bs-nav-link-color: var(--bs-body-color) !important;
--bs-nav-link-hover-color: var(--bs-primary) !important;
}
.nav-pills {
--bs-nav-pills-link-active-bg: var(--bs-primary) !important;
--bs-nav-pills-link-active-color: #fff !important;
}
.dropdown-menu {
--bs-dropdown-link-active-bg: var(--bs-primary) !important;
--bs-dropdown-link-active-color: #fff !important;
}
.navbar-nav .nav-link.active,
.navbar-nav .nav-link.show {
color: var(--bs-primary) !important;
}
/* 列表组 (List Group) */ /* 列表组 (List Group) */
.list-group-item.active { .list-group-item.active {
background-color: var(--bs-primary) !important; background-color: var(--bs-primary) !important;

View File

@ -385,7 +385,6 @@
if (existingNodes) { if (existingNodes) {
node._keyedNodes.delete(keyVal); node._keyedNodes.delete(keyVal);
existingNodes.forEach((child) => { existingNodes.forEach((child) => {
node.parentNode.insertBefore(child, node);
child._ref[indexName] = k; child._ref[indexName] = k;
child._ref[asName] = item; child._ref[asName] = item;
_scanTree(child); _scanTree(child);

View File

@ -1,90 +0,0 @@
import { test, expect } from '@playwright/test';
test('List fixtures cover normal, grouped, tree and virtual modes', async ({ page }) => {
await page.goto('/test/list_test.html');
await expect(page.locator('#listStd .list-group-item').first()).toBeVisible();
await expect(page.locator('#listGrp .list-group-item').first()).toBeVisible();
await expect(page.locator('#listTree .list-group-item').first()).toBeVisible();
await expect(page.locator('#listFast .list-group-item').first()).toBeVisible();
const treeBefore = await page.locator('#listTree .list-group-item').count();
await page.locator('#listTree .bi-caret-down-fill').first().click();
await page.waitForTimeout(100);
const treeAfter = await page.locator('#listTree .list-group-item').count();
expect(treeAfter).toBeLessThan(treeBefore);
const fastInfo = await page.locator('#listFast').evaluate(async node => {
const visibleText = () => Array.from(node.querySelectorAll('.list-group-item .fw-bold')).map(el => el.textContent.trim());
const initial = visibleText();
node.scrollTop = node.scrollHeight;
node.dispatchEvent(new Event('scroll'));
await new Promise(resolve => setTimeout(resolve, 300));
const final = visibleText();
return {
count: node.querySelectorAll('.list-group-item').length,
scrollTop: node.scrollTop,
initialFirst: initial[0],
finalFirst: final[0],
finalLast: final[final.length - 1]
};
});
expect(fastInfo.count).toBeGreaterThan(0);
expect(fastInfo.count).toBeLessThan(400);
expect(fastInfo.scrollTop).toBeGreaterThan(0);
expect(fastInfo.initialFirst).not.toBe(fastInfo.finalFirst);
expect(fastInfo.finalLast).toContain('Virtual Item');
});
test('List component supports internal filtering and sorting', async ({ page }) => {
await page.goto('/test/list_test.html');
// 1. Test Standard List Filtering
await page.fill('#searchInput', 'Item 5');
await page.waitForTimeout(100);
const itemsText = await page.locator('#listStd .list-group-item').allTextContents();
// Item 5, Item 50 should be there, but not Item 1, 2, etc.
expect(itemsText.every(text => text.includes('Item 5'))).toBe(true);
expect(itemsText.length).toBe(2); // Item 5 and Item 50
// Clear filter
await page.fill('#searchInput', '');
await page.waitForTimeout(100);
// 2. Test Standard List Sorting
// Default is: Item 1, Item 2...
await page.selectOption('#sortSelect', '-label');
await page.waitForTimeout(100);
const sortedText = await page.locator('#listStd .list-group-item').first().textContent();
expect(sortedText.trim()).toContain('Item 9'); // Lexicographically descending
// 3. Test Group List Filtering
await page.fill('#grpSearchInput', 'Member 3');
await page.waitForTimeout(100);
// Since grpItems have label 'Member X' and group fields. Only member matching '3' or '30' or '31' etc. will show.
const grpItemsCount = await page.locator('#listGrp .list-group-item').count();
// Should be filtered down considerably
expect(grpItemsCount).toBeLessThan(40);
// 4. Test Tree List Filtering (Crucial check for ancestry propagation)
await page.fill('#treeSearchInput', 'UI Team');
await page.waitForTimeout(100);
// UI Team should be visible
const uiTeam = page.locator('#listTree .list-group-item', { hasText: 'UI Team' });
await expect(uiTeam).toBeVisible();
// Ancestor "Company HQ" and "R&D Division" must also be visible
const parent1 = page.locator('#listTree .list-group-item', { hasText: 'Company HQ' });
const parent2 = page.locator('#listTree .list-group-item', { hasText: 'R&D Division' });
await expect(parent1).toBeVisible();
await expect(parent2).toBeVisible();
// Sibling node "Operations" or "Cloud Ops" should NOT be visible
const sibling1 = page.locator('#listTree .list-group-item', { hasText: 'Operations' });
const sibling2 = page.locator('#listTree .list-group-item', { hasText: 'Cloud Ops' });
await expect(sibling1).not.toBeVisible();
await expect(sibling2).not.toBeVisible();
});

View File

@ -16,32 +16,16 @@
<body> <body>
<div class="test-container"> <div class="test-container">
<div class="list-card"> <div class="list-card">
<h5 class="d-flex align-items-center justify-content-between"> <h5>1. Standard List (Normal)</h5>
<span>1. Standard List (Normal)</span> <List id="listStd" class="flex-fill" $.state.list="State.stdItems" auto-select></List>
<div class="d-flex gap-2">
<input type="text" id="searchInput" class="form-control form-control-sm" style="width: 120px;" placeholder="Search..." $bind="State.stdFilter">
<select id="sortSelect" class="form-select form-select-sm" style="width: 100px;" $bind="State.stdOrder">
<option value="">No Sort</option>
<option value="label">Label Asc</option>
<option value="-label">Label Desc</option>
</select>
</div>
</h5>
<List id="listStd" class="flex-fill" $.state.list="State.stdItems" $filter="State.stdFilter || ''" $order="State.stdOrder" auto-select></List>
</div> </div>
<div class="list-card"> <div class="list-card">
<h5 class="d-flex align-items-center justify-content-between"> <h5>2. Group List (Mode: Group)</h5>
<span>2. Group List (Mode: Group)</span> <List id="listGrp" mode="group" class="flex-fill" $.state.list="State.grpItems" $.state.groups="State.groups"></List>
<input type="text" id="grpSearchInput" class="form-control form-control-sm" style="width: 120px;" placeholder="Search..." $bind="State.grpFilter">
</h5>
<List id="listGrp" mode="group" class="flex-fill" $.state.list="State.grpItems" $.state.groups="State.groups" $filter="State.grpFilter || ''"></List>
</div> </div>
<div class="list-card"> <div class="list-card">
<h5 class="d-flex align-items-center justify-content-between"> <h5>3. Tree List (Mode: Tree + Collapsible)</h5>
<span>3. Tree List (Mode: Tree + Collapsible)</span> <List id="listTree" mode="tree" collapsible class="flex-fill" $.state.list="State.treeItems"></List>
<input type="text" id="treeSearchInput" class="form-control form-control-sm" style="width: 120px;" placeholder="Search..." $bind="State.treeFilter">
</h5>
<List id="listTree" mode="tree" collapsible class="flex-fill" $.state.list="State.treeItems" $filter="State.treeFilter || ''"></List>
</div> </div>
<div class="list-card"> <div class="list-card">
<h5>4. FAST Virtual List (10,000 Items + Dynamic Height)</h5> <h5>4. FAST Virtual List (10,000 Items + Dynamic Height)</h5>

59
test/mega_verify.spec.js Normal file
View File

@ -0,0 +1,59 @@
import { test, expect } from '@playwright/test';
test('Empirical Mega Verification', async ({ page }) => {
page.on('console', msg => console.log('BROWSER:', msg.text()));
// 1. Verify AutoForm
console.log('--- Verifying AutoForm Mega ---');
await page.goto('http://localhost:5173/test/form_test.html');
await page.waitForTimeout(3000);
const checkForm = async (id) => {
return await page.evaluate((fid) => {
const form = document.getElementById(fid);
const inputs = form.querySelectorAll('input, select, textarea');
const labels = form.querySelectorAll('label');
return {
id: fid,
inputCount: inputs.length,
labelCount: labels.length,
html: form.innerHTML.substring(0, 100)
};
}, id);
};
const vResult = await checkForm('formV');
const hResult = await checkForm('formH');
const iResult = await checkForm('formI');
console.log('Form results:', { vResult, hResult, iResult });
expect(vResult.inputCount).toBeGreaterThan(5);
expect(hResult.inputCount).toBeGreaterThan(5);
// 2. Verify List
console.log('--- Verifying List Mega ---');
await page.goto('http://localhost:5173/test/list_test.html');
await page.waitForTimeout(3000);
const checkList = async (id) => {
return await page.evaluate((lid) => {
const list = document.getElementById(lid);
const items = list.querySelectorAll('.list-group-item');
return {
id: lid,
itemCount: items.length,
labels: Array.from(items).map(i => i.textContent.trim())
};
}, id);
};
const stdResult = await checkList('listStd');
const grpResult = await checkList('listGrp');
const treeResult = await checkList('listTree');
console.log('List results:', { stdResult, grpResult, treeResult });
expect(stdResult.itemCount).toBeGreaterThan(0);
expect(grpResult.itemCount).toBeGreaterThan(2);
expect(treeResult.itemCount).toBeGreaterThan(0);
});

View File

@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test';
test('Stress test: Virtual List should handle 10,000 items with dynamic height', async ({ page }) => {
test.setTimeout(60000);
page.on('console', msg => console.log('BROWSER LOG:', msg.text()));
await page.goto('http://localhost:5174/test/list_test.html');
const listFast = page.locator('#listFast');
await expect(listFast).toBeVisible();
await page.waitForFunction(() => document.querySelectorAll('#listFast .list-group-item').length > 0);
// Check initial state
const scrollTop = await listFast.evaluate(e => e.scrollTop);
const scrollHeight = await listFast.evaluate(e => e.scrollHeight);
const clientHeight = await listFast.evaluate(e => e.clientHeight);
console.log(`Initial: scrollTop=${scrollTop}, scrollHeight=${scrollHeight}, clientHeight=${clientHeight}`);
// Attach event listener to see if scroll fires
await listFast.evaluate(e => {
e.addEventListener('scroll', () => console.log('SCROLL EVENT FIRED! new scrollTop:', e.scrollTop));
});
// Scroll to the very end
await listFast.evaluate(e => e.scrollTop = e.scrollHeight);
await page.waitForTimeout(1000);
const finalScrollTop = await listFast.evaluate(e => e.scrollTop);
console.log(`Final: scrollTop=${finalScrollTop}`);
const lastItemText = await listFast.locator('.list-group-item').last().textContent();
console.log('Last rendered item text:', lastItemText);
expect(lastItemText).toContain('Virtual Item 10000');
});

20
test/verify_style.spec.js Normal file
View File

@ -0,0 +1,20 @@
import { test, expect } from '@playwright/test';
test('Check styles and globals', async ({ page }) => {
await page.goto('http://localhost:5174/test/form_test.html');
// Check if auto-grid-form is grid (horizontal mode)
const formH = page.locator('#formH form');
const display = await formH.evaluate(el => window.getComputedStyle(el).display);
console.log('formH display:', display);
expect(display).toBe('grid');
// Get color picker input to check height
const colorInput = page.locator('#formH input[type="color"]');
if (await colorInput.count() > 0) {
const height = await colorInput.evaluate(el => window.getComputedStyle(el).height);
console.log('colorInput height:', height);
// It shouldn't be very small (like 0 or 2px)
expect(parseInt(height)).toBeGreaterThan(20);
}
});