Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2da203c513 | ||
|
|
93f6953f4c | ||
|
|
0b8e1b2952 | ||
|
|
754720685f | ||
|
|
3b83194265 |
@ -250,6 +250,8 @@
|
||||
- **`collapsible`** (Boolean): 仅在 `tree` 模式下有效,开启树形节点的展开/收起能力。
|
||||
- **`auto-select`** (Boolean): 开启后,点击列表项时自动将该项的 `id` 记录至 `state.selectedItem`,如果重复点击则会置空。
|
||||
- **`auto-select-group`** (Boolean): 开启后,点击分组时自动将分组的 `id` 记录至 `state.selectedGroup`。
|
||||
- **`filter`** (String): 筛选关键字。支持通过 attribute 传入以自动同步至 `state.filter`。
|
||||
- **`order`** (String): 排序规则。支持通过 attribute 传入以自动同步至 `state.order`。
|
||||
|
||||
### 4. 内部状态模型 (`State`)
|
||||
- `list` (读写): 原始列表数据数组。
|
||||
@ -257,6 +259,8 @@
|
||||
- `collapsed` (读写): 仅在 `tree` 且 `collapsible` 时使用,存储节点 ID 折叠状态的 Map Proxy。
|
||||
- `selectedItem` (读写): 当前选中的列表项 ID。
|
||||
- `selectedGroup` (读写): 当前选中的分组 ID。
|
||||
- `filter` (读写): 筛选关键字(进行 label 和 summary 的不区分大小写模糊匹配)或自定义过滤函数 `(item) => boolean`。
|
||||
- `order` (读写): 排序规则。可为排序字段名(例如 `'name'`;前缀 `-` 表示降序,如 `'-name'`;或者后缀 `'desc'`/`'asc'` 语法,如 `'name desc'`),也可以为自定义排序函数 `(a, b) => number`。
|
||||
- `_flatList` (只读): 列表核心逻辑处理后的扁平化数组。
|
||||
- `_renderedList` (只读): 实际在 DOM 中遍历渲染的数组片段(若开启虚拟滚动,则只包含可视区切片)。
|
||||
|
||||
|
||||
@ -1,5 +1,14 @@
|
||||
# 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
|
||||
### Fixed
|
||||
- **API**: Preserved declarative preset fields injected through `$.request.*` when the `API` component initializes its internal reactive request state.
|
||||
- **Nav**: Restored horizontal dropdown behavior by removing container-level clipping, added `label`/`class` support for menu grouping, and switched vertical mode dropdown groups to inline expansion so long sidebars can scroll without popup clipping.
|
||||
|
||||
## [1.0.18] - 2026-06-11
|
||||
### Changed
|
||||
- **Component-Based Refactor**: Reorganized source code into independent, PascalCase named modules (e.g., `AutoForm.js`, `TagsInput.js`, `List.js`) to improve maintainability.
|
||||
|
||||
13
README.md
13
README.md
@ -24,9 +24,9 @@
|
||||
<!-- 1. 基础状态机 (地基) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/state@1.0.18/dist/state.min.js"></script>
|
||||
<!-- 2. Bootstrap 适配层 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/bootstrap@1.0.7/dist/bootstrap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/bootstrap@1.0.9/dist/bootstrap.min.js"></script>
|
||||
<!-- 3. 本业务组件库 -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/base@1.0.18/dist/base.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@apigo.cc/base@1.0.22/dist/base.min.js"></script>
|
||||
```
|
||||
<script>
|
||||
// 4. 数据先行 (在 body 解析前定义)
|
||||
@ -205,6 +205,8 @@ const { HTTP, UI, AutoForm, State } = window.ApigoBase
|
||||
```
|
||||
* **AI 核心要点**:
|
||||
* **属性默认值**:当数据结构符合默认字段命名(`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` 类。
|
||||
|
||||
---
|
||||
@ -215,6 +217,7 @@ const { HTTP, UI, AutoForm, State } = window.ApigoBase
|
||||
// 1. 预先确定品牌与菜单数据
|
||||
const myBrand = { icon: 'shield-lock', label: '安全控制台' };
|
||||
const myNavList = [
|
||||
{ type: 'label', label: '主导航', class: 'mt-1' },
|
||||
{ type: 'button', name: 'dashboard', label: '仪表盘', icon: 'speedometer' },
|
||||
{
|
||||
type: 'dropdown',
|
||||
@ -222,7 +225,8 @@ const { HTTP, UI, AutoForm, State } = window.ApigoBase
|
||||
label: '系统设置',
|
||||
icon: 'gear',
|
||||
list: [
|
||||
{ type: 'button', name: 'profile', label: '个人信息', icon: 'person' },
|
||||
{ type: 'label', label: '偏好设置' },
|
||||
{ type: 'button', name: 'profile', label: '个人信息', icon: 'person', class: 'ps-2' },
|
||||
{ type: 'switch', name: 'darkMode', label: '暗黑模式', icon: 'moon', bind: LocalStorage, name: 'darkMode' }
|
||||
]
|
||||
},
|
||||
@ -241,6 +245,9 @@ const { HTTP, UI, AutoForm, State } = window.ApigoBase
|
||||
```
|
||||
* **AI 核心要点**:
|
||||
* 常规导航项被点击时(且 `noselect` 不为 true),会自动更新全局 `Hash.nav = item.name`。
|
||||
* `label` 可在一级或二级菜单中作为分组标题使用,`class` 可附加 Bootstrap 工具类(如 `ps-2`)做缩进或样式微调。
|
||||
* `dropdown` 子项默认只派发 `nav` 事件,不自动写入 `Hash.nav`,适合承载偏好设置、开关等功能菜单。
|
||||
* 垂直模式下二级菜单以内联展开方式显示,外层布局需提供局部滚动边界。
|
||||
|
||||
---
|
||||
|
||||
|
||||
38
TEST.md
38
TEST.md
@ -1,21 +1,23 @@
|
||||
# base Benchmark & Test Results
|
||||
# base Test Notes
|
||||
|
||||
## 基准测试 (Benchmark)
|
||||
*测试环境: Playwright / Chromium*
|
||||
当前测试只保留可维护的 Playwright 冒烟用例,统一直接打开 `test/` 里的 UMD 示例页,不再依赖历史上的浏览器内自测脚本或混合 ESM 入口。
|
||||
|
||||
| 指标 | 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 |
|
||||
## 运行方式
|
||||
|
||||
## 测试覆盖 (Coverage)
|
||||
- [x] HTTP Request (GET/POST)
|
||||
- [x] API Component (Auto mode)
|
||||
- [x] Modal / Dialog / Toast UI
|
||||
- [x] AutoForm (Rendering & Validation)
|
||||
- [x] DatePicker / ColorPicker / IconPicker (New)
|
||||
- [x] FastList (Virtual Scrolling)
|
||||
- [x] Tree / Nav / Resizer
|
||||
- [x] Virtual List dynamic sizing check
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
测试命令会先执行 `npm run build`,然后自动把最新的 `state/bootstrap/base` 产物同步到 `test/lib`,最后再跑 Playwright。这样可以避免 `test/lib` 副本过期,但又不依赖跨目录静态服务配置。
|
||||
|
||||
Playwright 会自动启动本地 Vite 静态服务,并执行以下用例:
|
||||
|
||||
- `test/api.spec.js`:验证 `<API>` 会保留声明式 `$.request.*` 预设字段。
|
||||
- `test/form.spec.js`:验证 `AutoForm` 的垂直、水平、强制水平、inline 等布局。
|
||||
- `test/list.spec.js`:验证普通列表、分组列表、树形列表和虚拟滚动列表。
|
||||
- `test/capability.spec.js`:验证示例页里的 `Nav` 切换和主要区域渲染。
|
||||
|
||||
## 取舍原则
|
||||
|
||||
- 删除了端口写死、依赖旧 ESM 入口、只输出调试信息却没有稳定断言价值的历史用例。
|
||||
- 不再把性能 benchmark 当成默认测试流程的一部分,避免未来维护被脆弱用例卡住。
|
||||
|
||||
261
dist/base.js
vendored
261
dist/base.js
vendored
@ -399,9 +399,31 @@
|
||||
}
|
||||
globalThis.MouseMover = MouseMover;
|
||||
const APIComponent = globalThis.Component.register("API", (container) => {
|
||||
container.request = globalThis.NewState({ url: "", method: "GET", headers: {}, data: null, timeout: 1e4, responseType: "" });
|
||||
container.response = globalThis.NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: "", result: null });
|
||||
container.result = globalThis.NewState();
|
||||
const presetRequest = container.request && typeof container.request === "object" ? container.request : {};
|
||||
const presetResponse = container.response && typeof container.response === "object" ? container.response : {};
|
||||
const presetResult = container.result && typeof container.result === "object" ? container.result : {};
|
||||
container.request = globalThis.NewState({
|
||||
url: "",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
data: null,
|
||||
timeout: 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 = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = { ...container.request, ...opt };
|
||||
@ -740,37 +762,142 @@
|
||||
container.collapsed = globalThis.NewState({});
|
||||
container.state.renderedList = [];
|
||||
const updateFlatList = () => {
|
||||
globalThis.Util.updateDefaults(container, defaultSets);
|
||||
const list = container.state.list || [], flatList = [];
|
||||
if (container.mode === "group") {
|
||||
const itemMap = {};
|
||||
list.forEach((item) => {
|
||||
var _a;
|
||||
return (itemMap[_a = item[container.groupfield]] ?? (itemMap[_a] = [])).push(item);
|
||||
globalThis.Util.updateDefaults(container, defaultSets);
|
||||
let list = container.state.list || [];
|
||||
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") {
|
||||
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 = {};
|
||||
filteredList.forEach((item) => {
|
||||
var _a2;
|
||||
return (itemMap[_a2 = item[container.groupfield]] ?? (itemMap[_a2] = [])).push(item);
|
||||
});
|
||||
(container.state.groups || []).forEach((group) => {
|
||||
flatList.push({ type: "group", ...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 });
|
||||
if (items) items.forEach((item) => flatList.push({ type: "item", ...item }));
|
||||
}
|
||||
});
|
||||
} else if (container.mode === "tree") {
|
||||
const itemMap = {};
|
||||
list.forEach((item) => {
|
||||
itemMap[item[container.idfield]] = item;
|
||||
});
|
||||
const matches = /* @__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 = {};
|
||||
list.forEach((item) => {
|
||||
var _a;
|
||||
return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
|
||||
if (!filter || keep.has(item[container.idfield])) {
|
||||
const parentId = item[container.parentfield] || "";
|
||||
(childrenMap[parentId] ?? (childrenMap[parentId] = [])).push(item);
|
||||
}
|
||||
});
|
||||
const traverse = (items, level, parents) => items.forEach((item) => {
|
||||
var _a;
|
||||
const id = item[container.idfield], hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.length);
|
||||
var _a2;
|
||||
const id = item[container.idfield];
|
||||
const hasChildren = !!((_a2 = childrenMap[id]) == null ? void 0 : _a2.length);
|
||||
const isCollapsed = container.collapsed[id];
|
||||
flatList.push({ type: "item", ...item, _level: level, _hasChildren: hasChildren, _parents: parents });
|
||||
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]);
|
||||
});
|
||||
traverse(childrenMap[""] || [], 0, []);
|
||||
} else list.forEach((item) => flatList.push({ type: "item", ...item }));
|
||||
} else {
|
||||
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.__watch("list", updateFlatList);
|
||||
container.state.__watch("filter", updateFlatList);
|
||||
container.state.__watch("order", updateFlatList);
|
||||
const vs = container.fast ? globalThis.VirtualScroll() : null;
|
||||
let refreshing = false;
|
||||
container.refresh = () => {
|
||||
@ -818,6 +945,31 @@
|
||||
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();
|
||||
}, globalThis.Util.makeDom(
|
||||
/*html*/
|
||||
@ -860,55 +1012,104 @@
|
||||
));
|
||||
globalThis.Component.register("Nav", (container) => {
|
||||
container.vertical = container.hasAttribute("vertical");
|
||||
container.state.openName = container.state.openName || null;
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) globalThis.Hash.nav = item.name;
|
||||
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(
|
||||
/*html*/
|
||||
`
|
||||
<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="\${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">
|
||||
<template $if="this.state?.brand?.image">
|
||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'mx-1'}" style="height:30px;width:auto;max-width:300px">
|
||||
</template>
|
||||
<template $if="this.state?.brand?.icon">
|
||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
|
||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'mx-1'}"></i>
|
||||
</template>
|
||||
<template $if="this.state?.brand?.label">
|
||||
<span $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
|
||||
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'mx-1'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
|
||||
</template>
|
||||
<div $class="\${this.vertical ? 'w-100' : 'ms-2'}"></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;' : ''">
|
||||
<template $each="this.state?.list || []">
|
||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}" style="transform: translateY(3px);">
|
||||
<template $if="item.type==='label'">
|
||||
<div $class="\${this.vertical ? 'small text-uppercase text-body-secondary fw-semibold px-2 py-1 mt-2' : 'navbar-text small text-uppercase text-body-secondary fw-semibold px-2'} \${item.class || ''}" $text="item.label"></div>
|
||||
</template>
|
||||
<template $if="item.type==='button'">
|
||||
<button $class="nav-link \${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 ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
<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)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? 'text-truncate' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="item.type==='dropdown'">
|
||||
<div class="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="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
<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>
|
||||
<div $class="dropdown-menu \${this.vertical?'position-static':'dropdown-menu-end'} p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
|
||||
<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 px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<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">
|
||||
<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"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template $if="!this.vertical">
|
||||
<div class="dropdown">
|
||||
<button type="button" $class="nav-link dropdown-toggle \${item.class || ''}" data-bs-toggle="dropdown">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="'d-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'" $text="item.label"></span>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end p-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px;'">
|
||||
<template $each="item.list" as="subitem">
|
||||
<template $if="subitem.type==='label'">
|
||||
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>
|
||||
</template>
|
||||
<template $if="subitem.type==='button'">
|
||||
<button $class="dropdown-item \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="subitem.type==='switch'">
|
||||
<div $class="d-flex align-items-center px-3 py-2 \${subitem.class || ''}">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
));
|
||||
|
||||
2
dist/base.min.js
vendored
2
dist/base.min.js
vendored
File diff suppressed because one or more lines are too long
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@apigo.cc/base",
|
||||
"version": "1.0.19",
|
||||
"version": "1.0.22",
|
||||
"type": "module",
|
||||
"main": "dist/base.js",
|
||||
"files": [
|
||||
@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"test": "playwright test",
|
||||
"test": "npm run build && node scripts/prepare-test-lib.js && playwright test",
|
||||
"pub": "node scripts/publish.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -3,12 +3,13 @@ import { defineConfig } from '@playwright/test';
|
||||
export default defineConfig({
|
||||
testDir: './test',
|
||||
testMatch: '**/*.spec.js',
|
||||
reporter: 'line',
|
||||
use: {
|
||||
baseURL: 'http://127.0.0.1:8082',
|
||||
},
|
||||
webServer: {
|
||||
command: 'npx vite --port 8082 --host 127.0.0.1',
|
||||
url: 'http://127.0.0.1:8082/test/index.html',
|
||||
url: 'http://127.0.0.1:8082/test/form_test.html',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
|
||||
22
scripts/prepare-test-lib.js
Normal file
22
scripts/prepare-test-lib.js
Normal file
@ -0,0 +1,22 @@
|
||||
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);
|
||||
}
|
||||
@ -22,11 +22,9 @@ try {
|
||||
const pkgPath = path.join(__dirname, '../package.json');
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
|
||||
|
||||
// 保持原有名称(如果已经带有 @apigo.cc/ 前缀)或替换前缀
|
||||
if (!pkg.name.startsWith('@apigo.cc/')) {
|
||||
const baseName = pkg.name.includes('/') ? pkg.name.split('/')[1] : pkg.name;
|
||||
// npm 要求包名全小写,scope + name 强制 toLowerCase
|
||||
const baseName = (pkg.name.includes('/') ? pkg.name.split('/')[1] : pkg.name).toLowerCase();
|
||||
pkg.name = `@apigo.cc/${baseName}`;
|
||||
}
|
||||
pkg.version = version;
|
||||
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
||||
|
||||
29
src/API.js
29
src/API.js
@ -2,9 +2,32 @@
|
||||
* API Component Module
|
||||
*/
|
||||
const APIComponent = globalThis.Component.register('API', container => {
|
||||
container.request = globalThis.NewState({ url: '', method: 'GET', headers: {}, data: null, timeout: 10000, responseType: '' })
|
||||
container.response = globalThis.NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: '', result: null })
|
||||
container.result = globalThis.NewState()
|
||||
const presetRequest = container.request && typeof container.request === 'object' ? container.request : {}
|
||||
const presetResponse = container.response && typeof container.response === 'object' ? container.response : {}
|
||||
const presetResult = container.result && typeof container.result === 'object' ? container.result : {}
|
||||
|
||||
container.request = globalThis.NewState({
|
||||
url: '',
|
||||
method: 'GET',
|
||||
headers: {},
|
||||
data: null,
|
||||
timeout: 10000,
|
||||
responseType: '',
|
||||
...presetRequest,
|
||||
headers: { ...(presetRequest.headers || {}) }
|
||||
})
|
||||
container.response = globalThis.NewState({
|
||||
loading: false,
|
||||
ok: null,
|
||||
status: null,
|
||||
error: null,
|
||||
headers: {},
|
||||
responseType: '',
|
||||
result: null,
|
||||
...presetResponse,
|
||||
headers: { ...(presetResponse.headers || {}) }
|
||||
})
|
||||
container.result = globalThis.NewState(presetResult)
|
||||
container.do = (opt = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = { ...container.request, ...opt }
|
||||
|
||||
148
src/list.js
148
src/list.js
@ -19,30 +19,142 @@ globalThis.Component.register('List', container => {
|
||||
|
||||
const updateFlatList = () => {
|
||||
globalThis.Util.updateDefaults(container, defaultSets)
|
||||
const list = container.state.list || [], flatList = []
|
||||
let list = container.state.list || []
|
||||
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') {
|
||||
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 = {}
|
||||
list.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
|
||||
filteredList.forEach(item => (itemMap[item[container.groupfield]] ??= []).push(item));
|
||||
(container.state.groups || []).forEach(group => {
|
||||
flatList.push({ type: 'group', ...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 })
|
||||
if (items) items.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||
}
|
||||
})
|
||||
} else if (container.mode === 'tree') {
|
||||
const itemMap = {}
|
||||
list.forEach(item => { itemMap[item[container.idfield]] = item })
|
||||
|
||||
const matches = new Set()
|
||||
list.forEach(item => {
|
||||
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 = {}
|
||||
list.forEach(item => (childrenMap[item[container.parentfield] || ''] ??= []).push(item))
|
||||
list.forEach(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 id = item[container.idfield], hasChildren = !!childrenMap[id]?.length
|
||||
const id = item[container.idfield]
|
||||
const hasChildren = !!childrenMap[id]?.length
|
||||
const isCollapsed = container.collapsed[id]
|
||||
flatList.push({ type: 'item', ...item, _level: level, _hasChildren: hasChildren, _parents: parents })
|
||||
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id])
|
||||
})
|
||||
traverse(childrenMap[''] || [], 0, [])
|
||||
} else list.forEach(item => flatList.push({ type: 'item', ...item }))
|
||||
} else {
|
||||
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.__watch('list', updateFlatList)
|
||||
container.state.__watch('filter', updateFlatList)
|
||||
container.state.__watch('order', updateFlatList)
|
||||
const vs = container.fast ? globalThis.VirtualScroll() : null
|
||||
|
||||
let refreshing = false
|
||||
@ -84,6 +196,30 @@ globalThis.Component.register('List', container => {
|
||||
}
|
||||
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()
|
||||
}, globalThis.Util.makeDom(/*html*/`
|
||||
<div class="list-group overflow-auto" onscroll="this.refresh()" style="overflow-anchor:none">
|
||||
|
||||
79
src/nav.js
79
src/nav.js
@ -3,52 +3,101 @@
|
||||
*/
|
||||
globalThis.Component.register('Nav', container => {
|
||||
container.vertical = container.hasAttribute('vertical')
|
||||
container.state.openName = container.state.openName || null
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) globalThis.Hash.nav = item.name
|
||||
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(/*html*/`
|
||||
<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="\${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">
|
||||
<template $if="this.state?.brand?.image">
|
||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'mx-1'}" style="height:30px;width:auto;max-width:300px">
|
||||
</template>
|
||||
<template $if="this.state?.brand?.icon">
|
||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
|
||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'mx-1'}"></i>
|
||||
</template>
|
||||
<template $if="this.state?.brand?.label">
|
||||
<span $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
|
||||
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'mx-1'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
|
||||
</template>
|
||||
<div $class="\${this.vertical ? 'w-100' : 'ms-2'}"></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;' : ''">
|
||||
<template $each="this.state?.list || []">
|
||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}" style="transform: translateY(3px);">
|
||||
<template $if="item.type==='label'">
|
||||
<div $class="\${this.vertical ? 'small text-uppercase text-body-secondary fw-semibold px-2 py-1 mt-2' : 'navbar-text small text-uppercase text-body-secondary fw-semibold px-2'} \${item.class || ''}" $text="item.label"></div>
|
||||
</template>
|
||||
<template $if="item.type==='button'">
|
||||
<button $class="nav-link \${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 ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
<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)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? 'text-truncate' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="item.type==='dropdown'">
|
||||
<div class="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="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
<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>
|
||||
<div $class="dropdown-menu \${this.vertical?'position-static':'dropdown-menu-end'} p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
|
||||
<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 px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<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">
|
||||
<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"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template $if="!this.vertical">
|
||||
<div class="dropdown">
|
||||
<button type="button" $class="nav-link dropdown-toggle \${item.class || ''}" data-bs-toggle="dropdown">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="'d-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'" $text="item.label"></span>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end p-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px;'">
|
||||
<template $each="item.list" as="subitem">
|
||||
<template $if="subitem.type==='label'">
|
||||
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>
|
||||
</template>
|
||||
<template $if="subitem.type==='button'">
|
||||
<button $class="dropdown-item \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="subitem.type==='switch'">
|
||||
<div $class="d-flex align-items-center px-3 py-2 \${subitem.class || ''}">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
`))
|
||||
|
||||
@ -1,66 +0,0 @@
|
||||
# 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 |
|
||||
```
|
||||
@ -1,76 +0,0 @@
|
||||
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`);
|
||||
});
|
||||
}
|
||||
});
|
||||
36
test/api.spec.js
Normal file
36
test/api.spec.js
Normal file
@ -0,0 +1,36 @@
|
||||
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' }));
|
||||
});
|
||||
20
test/api_test.html
Normal file
20
test/api_test.html
Normal file
@ -0,0 +1,20 @@
|
||||
<!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>
|
||||
@ -1,32 +0,0 @@
|
||||
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
|
||||
});
|
||||
@ -1,133 +0,0 @@
|
||||
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();
|
||||
|
||||
// 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.');
|
||||
}
|
||||
14
test/capability.spec.js
Normal file
14
test/capability.spec.js
Normal file
@ -0,0 +1,14 @@
|
||||
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();
|
||||
});
|
||||
@ -1,29 +0,0 @@
|
||||
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);
|
||||
});
|
||||
21
test/form.spec.js
Normal file
21
test/form.spec.js
Normal file
@ -0,0 +1,21 @@
|
||||
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
116
test/index.html
@ -1,116 +0,0 @@
|
||||
<!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>
|
||||
291
test/lib/base.js
291
test/lib/base.js
@ -30,11 +30,24 @@
|
||||
}
|
||||
options.body = data;
|
||||
}
|
||||
for (const name of HTTP.keepHeaders) {
|
||||
if (HTTP._keepHeadersCache[name] && !headers[name]) headers[name] = HTTP._keepHeadersCache[name];
|
||||
}
|
||||
if (Object.keys(headers).length) options.headers = headers;
|
||||
const response = { error: null, ok: null, status: 0, headers: {}, responseType: "", result: null };
|
||||
try {
|
||||
const resp = await fetch(url, options);
|
||||
Object.assign(response, { ok: resp.ok, status: resp.status, headers: Object.fromEntries(resp.headers.entries()) });
|
||||
for (const name of HTTP.keepHeaders) {
|
||||
const val = resp.headers.get(name);
|
||||
if (val) {
|
||||
HTTP._keepHeadersCache[name] = val;
|
||||
try {
|
||||
localStorage.setItem("_http_keep_" + name, val);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!responseType) {
|
||||
const contentType = resp.headers.get("Content-Type") || "";
|
||||
if (contentType.includes("application/json")) responseType = "json";
|
||||
@ -51,6 +64,15 @@
|
||||
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;
|
||||
const UI = {};
|
||||
globalThis.Component.register("Toast", (container) => {
|
||||
@ -127,14 +149,14 @@
|
||||
<option $each="item.options" as="opt" $value="opt.value !== undefined ? opt.value : opt" $text="opt.label || opt"></option>
|
||||
</select>
|
||||
|
||||
<div $if="['checkbox', 'radio'].includes(item.type)" class="d-flex align-items-center flex-wrap gap-3 h-100">
|
||||
<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);">
|
||||
<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:'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.value !== undefined ? opt.value : 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>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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;">
|
||||
<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;">
|
||||
<input $name="item.name" class="form-check-input m-0" style="float:none; cursor:pointer" type="checkbox" $bind="this.data[item.name]">
|
||||
</div>
|
||||
|
||||
@ -377,9 +399,31 @@
|
||||
}
|
||||
globalThis.MouseMover = MouseMover;
|
||||
const APIComponent = globalThis.Component.register("API", (container) => {
|
||||
container.request = globalThis.NewState({ url: "", method: "GET", headers: {}, data: null, timeout: 1e4, responseType: "" });
|
||||
container.response = globalThis.NewState({ loading: false, ok: null, status: null, error: null, headers: {}, responseType: "", result: null });
|
||||
container.result = globalThis.NewState();
|
||||
const presetRequest = container.request && typeof container.request === "object" ? container.request : {};
|
||||
const presetResponse = container.response && typeof container.response === "object" ? container.response : {};
|
||||
const presetResult = container.result && typeof container.result === "object" ? container.result : {};
|
||||
container.request = globalThis.NewState({
|
||||
url: "",
|
||||
method: "GET",
|
||||
headers: {},
|
||||
data: null,
|
||||
timeout: 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 = {}) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = { ...container.request, ...opt };
|
||||
@ -513,7 +557,7 @@
|
||||
}
|
||||
})}' $text="item"></button>
|
||||
</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" $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" $onchange="event.stopPropagation()" $oninput="event.stopPropagation()" $onkeydown='${globalThis.Util.getFunctionBody(function(event) {
|
||||
if (event.isComposing) return;
|
||||
if (["Enter", ",", " "].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
@ -718,37 +762,142 @@
|
||||
container.collapsed = globalThis.NewState({});
|
||||
container.state.renderedList = [];
|
||||
const updateFlatList = () => {
|
||||
globalThis.Util.updateDefaults(container, defaultSets);
|
||||
const list = container.state.list || [], flatList = [];
|
||||
if (container.mode === "group") {
|
||||
const itemMap = {};
|
||||
list.forEach((item) => {
|
||||
var _a;
|
||||
return (itemMap[_a = item[container.groupfield]] ?? (itemMap[_a] = [])).push(item);
|
||||
globalThis.Util.updateDefaults(container, defaultSets);
|
||||
let list = container.state.list || [];
|
||||
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") {
|
||||
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 = {};
|
||||
filteredList.forEach((item) => {
|
||||
var _a2;
|
||||
return (itemMap[_a2 = item[container.groupfield]] ?? (itemMap[_a2] = [])).push(item);
|
||||
});
|
||||
(container.state.groups || []).forEach((group) => {
|
||||
flatList.push({ type: "group", ...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 });
|
||||
if (items) items.forEach((item) => flatList.push({ type: "item", ...item }));
|
||||
}
|
||||
});
|
||||
} else if (container.mode === "tree") {
|
||||
const itemMap = {};
|
||||
list.forEach((item) => {
|
||||
itemMap[item[container.idfield]] = item;
|
||||
});
|
||||
const matches = /* @__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 = {};
|
||||
list.forEach((item) => {
|
||||
var _a;
|
||||
return (childrenMap[_a = item[container.parentfield] || ""] ?? (childrenMap[_a] = [])).push(item);
|
||||
if (!filter || keep.has(item[container.idfield])) {
|
||||
const parentId = item[container.parentfield] || "";
|
||||
(childrenMap[parentId] ?? (childrenMap[parentId] = [])).push(item);
|
||||
}
|
||||
});
|
||||
const traverse = (items, level, parents) => items.forEach((item) => {
|
||||
var _a;
|
||||
const id = item[container.idfield], hasChildren = !!((_a = childrenMap[id]) == null ? void 0 : _a.length);
|
||||
var _a2;
|
||||
const id = item[container.idfield];
|
||||
const hasChildren = !!((_a2 = childrenMap[id]) == null ? void 0 : _a2.length);
|
||||
const isCollapsed = container.collapsed[id];
|
||||
flatList.push({ type: "item", ...item, _level: level, _hasChildren: hasChildren, _parents: parents });
|
||||
if (hasChildren && !isCollapsed) traverse(childrenMap[id], level + 1, [...parents, id]);
|
||||
});
|
||||
traverse(childrenMap[""] || [], 0, []);
|
||||
} else list.forEach((item) => flatList.push({ type: "item", ...item }));
|
||||
} else {
|
||||
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.__watch("list", updateFlatList);
|
||||
container.state.__watch("filter", updateFlatList);
|
||||
container.state.__watch("order", updateFlatList);
|
||||
const vs = container.fast ? globalThis.VirtualScroll() : null;
|
||||
let refreshing = false;
|
||||
container.refresh = () => {
|
||||
@ -796,6 +945,31 @@
|
||||
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();
|
||||
}, globalThis.Util.makeDom(
|
||||
/*html*/
|
||||
@ -838,55 +1012,104 @@
|
||||
));
|
||||
globalThis.Component.register("Nav", (container) => {
|
||||
container.vertical = container.hasAttribute("vertical");
|
||||
container.state.openName = container.state.openName || null;
|
||||
container.click = (item, noselect) => {
|
||||
if (!item.noselect && !noselect) globalThis.Hash.nav = item.name;
|
||||
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(
|
||||
/*html*/
|
||||
`
|
||||
<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="\${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">
|
||||
<template $if="this.state?.brand?.image">
|
||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-4' : 'me-2'}" style="height:30px;width:auto;max-width:300px">
|
||||
<img $src="this.state.brand.image" $class="\${this.vertical ? 'mb-2' : 'mx-1'}" style="height:30px;width:auto;max-width:300px">
|
||||
</template>
|
||||
<template $if="this.state?.brand?.icon">
|
||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-4' : 'me-2'}"></i>
|
||||
<i $class="bi bi-\${this.state.brand.icon} \${this.vertical ? 'mb-2' : 'mx-1'}"></i>
|
||||
</template>
|
||||
<template $if="this.state?.brand?.label">
|
||||
<span $class="\${this.vertical ? 'mb-4 fw-bold' : 'me-2'}" $text="this.state.brand.label"></span>
|
||||
<span $class="\${this.vertical ? 'mb-2 fw-bold' : 'mx-1'}" style="transform: translateY(3px);" $text="this.state.brand.label"></span>
|
||||
</template>
|
||||
<div $class="\${this.vertical ? 'w-100' : 'ms-2'}"></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;' : ''">
|
||||
<template $each="this.state?.list || []">
|
||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100' : 'navbar-nav'} text-truncate \${item.type==='fill'?'flex-fill':''}">
|
||||
<div $class="\${this.vertical ? 'nav nav-pills flex-column w-100 position-relative' : 'navbar-nav flex-row align-items-center'} \${!this.vertical && item.type==='fill' ? 'flex-fill' : ''}" style="transform: translateY(3px);">
|
||||
<template $if="item.type==='label'">
|
||||
<div $class="\${this.vertical ? 'small text-uppercase text-body-secondary fw-semibold px-2 py-1 mt-2' : 'navbar-text small text-uppercase text-body-secondary fw-semibold px-2'} \${item.class || ''}" $text="item.label"></div>
|
||||
</template>
|
||||
<template $if="item.type==='button'">
|
||||
<button $class="nav-link \${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 ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
<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)">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="\${this.vertical ? 'text-truncate' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="item.type==='dropdown'">
|
||||
<div class="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="\${this.vertical ? '' : 'd-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'}" $text="item.label"></span>
|
||||
<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>
|
||||
<div $class="dropdown-menu \${this.vertical?'position-static':'dropdown-menu-end'} p-3 bg-body-secondary shadow" $style="width: \${item.width || 250}px;">
|
||||
<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 px-0 w-100 text-start" $onclick="this.click(subitem, true)">
|
||||
<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">
|
||||
<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"><input class="form-check-input mx-0" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<template $if="!this.vertical">
|
||||
<div class="dropdown">
|
||||
<button type="button" $class="nav-link dropdown-toggle \${item.class || ''}" data-bs-toggle="dropdown">
|
||||
<i $class="bi bi-\${item.icon} me-2"></i><span $class="'d-none d-' + (this.state?.list?.length>5?'lg':'md') + '-inline'" $text="item.label"></span>
|
||||
</button>
|
||||
<div class="dropdown-menu dropdown-menu-end p-2 bg-body-secondary shadow" $style="'width: ' + (item.width || 250) + 'px;'">
|
||||
<template $each="item.list" as="subitem">
|
||||
<template $if="subitem.type==='label'">
|
||||
<div $class="dropdown-header \${subitem.class || ''}" $text="subitem.label"></div>
|
||||
</template>
|
||||
<template $if="subitem.type==='button'">
|
||||
<button $class="dropdown-item \${subitem.class || ''}" $onclick="this.clickSubitem(subitem)">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
<template $if="subitem.type==='switch'">
|
||||
<div $class="d-flex align-items-center px-3 py-2 \${subitem.class || ''}">
|
||||
<i $class="bi bi-\${subitem.icon} me-2 d-inline-block" style="width: 16px;"></i><span $text="subitem.label"></span><div class="flex-fill"></div>
|
||||
<div class="form-switch ms-2"><input class="form-check-input" type="checkbox" $bind="subitem.bind[subitem.name]"></div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
));
|
||||
|
||||
20
test/lib/bootstrap.js
vendored
20
test/lib/bootstrap.js
vendored
@ -2121,7 +2121,7 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
|
||||
}
|
||||
})();
|
||||
(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 = {}));
|
||||
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 || {}));
|
||||
})(this, function(exports2) {
|
||||
"use strict";
|
||||
var top = "top";
|
||||
@ -7233,6 +7233,24 @@ url("data:font/woff;base64,d09GRgABAAAAAsBAAAsAAAAHavgAAQAAAAAAAAAAAAAAAAAAAAAAA
|
||||
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-item.active {
|
||||
background-color: var(--bs-primary) !important;
|
||||
|
||||
@ -385,6 +385,7 @@
|
||||
if (existingNodes) {
|
||||
node._keyedNodes.delete(keyVal);
|
||||
existingNodes.forEach((child) => {
|
||||
node.parentNode.insertBefore(child, node);
|
||||
child._ref[indexName] = k;
|
||||
child._ref[asName] = item;
|
||||
_scanTree(child);
|
||||
|
||||
90
test/list.spec.js
Normal file
90
test/list.spec.js
Normal file
@ -0,0 +1,90 @@
|
||||
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();
|
||||
});
|
||||
|
||||
@ -16,16 +16,32 @@
|
||||
<body>
|
||||
<div class="test-container">
|
||||
<div class="list-card">
|
||||
<h5>1. Standard List (Normal)</h5>
|
||||
<List id="listStd" class="flex-fill" $.state.list="State.stdItems" auto-select></List>
|
||||
<h5 class="d-flex align-items-center justify-content-between">
|
||||
<span>1. Standard List (Normal)</span>
|
||||
<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 class="list-card">
|
||||
<h5>2. Group List (Mode: Group)</h5>
|
||||
<List id="listGrp" mode="group" class="flex-fill" $.state.list="State.grpItems" $.state.groups="State.groups"></List>
|
||||
<h5 class="d-flex align-items-center justify-content-between">
|
||||
<span>2. Group List (Mode: Group)</span>
|
||||
<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 class="list-card">
|
||||
<h5>3. Tree List (Mode: Tree + Collapsible)</h5>
|
||||
<List id="listTree" mode="tree" collapsible class="flex-fill" $.state.list="State.treeItems"></List>
|
||||
<h5 class="d-flex align-items-center justify-content-between">
|
||||
<span>3. Tree List (Mode: Tree + Collapsible)</span>
|
||||
<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 class="list-card">
|
||||
<h5>4. FAST Virtual List (10,000 Items + Dynamic Height)</h5>
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@ -1,34 +0,0 @@
|
||||
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');
|
||||
});
|
||||
@ -1,20 +0,0 @@
|
||||
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);
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user