From 2da203c5135bdafdf5479bb1422f6fe01890360b Mon Sep 17 00:00:00 2001 From: AI Engineer Date: Sun, 28 Jun 2026 12:00:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(base):=20=E5=88=97=E8=A1=A8=E7=BB=84?= =?UTF-8?q?=E4=BB=B6=E5=A2=9E=E5=8A=A0=E7=AD=9B=E9=80=89=E5=92=8C=E8=BF=87?= =?UTF-8?q?=E6=BB=A4=E6=8E=92=E5=BA=8F=E5=8A=9F=E8=83=BD=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=99=AE=E9=80=9A/=E5=88=86=E7=BB=84/=E6=A0=91?= =?UTF-8?q?=E5=BD=A2=E6=A8=A1=E5=BC=8F=E5=8F=8A=E5=B1=9E=E6=80=A7=E8=81=94?= =?UTF-8?q?=E5=8A=A8=EF=BC=88by=20AI=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CAPABILITY.md | 4 + CHANGELOG.md | 4 + README.md | 4 +- TEST.md | 38 +- dist/base.js | 158 ++++++++- dist/base.min.js | 2 +- package.json | 4 +- playwright.config.js | 3 +- scripts/prepare-test-lib.js | 22 ++ src/list.js | 150 +++++++- src/nav.js | 6 +- .../error-context.md | 66 ---- test/all.spec.js | 76 ---- test/api.spec.js | 36 ++ test/api_test.html | 20 ++ test/atomic_check.spec.js | 32 -- test/base.test.js | 162 --------- test/capability.spec.js | 14 + test/deep_dump.spec.js | 29 -- test/form.spec.js | 21 ++ test/index.html | 116 ------ test/lib/base.js | 333 +++++++++++++++--- test/lib/bootstrap.js | 20 +- test/lib/state.js | 1 + test/list.spec.js | 90 +++++ test/list_test.html | 28 +- test/mega_verify.spec.js | 59 ---- test/verify_stress.spec.js | 34 -- test/verify_style.spec.js | 20 -- 29 files changed, 849 insertions(+), 703 deletions(-) create mode 100644 scripts/prepare-test-lib.js delete mode 100644 test-results/atomic_check-Capability-demo-page-atomic-tests-verification/error-context.md delete mode 100644 test/all.spec.js create mode 100644 test/api.spec.js create mode 100644 test/api_test.html delete mode 100644 test/atomic_check.spec.js delete mode 100644 test/base.test.js create mode 100644 test/capability.spec.js delete mode 100644 test/deep_dump.spec.js create mode 100644 test/form.spec.js delete mode 100644 test/index.html create mode 100644 test/list.spec.js delete mode 100644 test/mega_verify.spec.js delete mode 100644 test/verify_stress.spec.js delete mode 100644 test/verify_style.spec.js diff --git a/CAPABILITY.md b/CAPABILITY.md index 8903ed7..c22626a 100644 --- a/CAPABILITY.md +++ b/CAPABILITY.md @@ -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 中遍历渲染的数组片段(若开启虚拟滚动,则只包含可视区切片)。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c44d5a..802de85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # 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. diff --git a/README.md b/README.md index 56cfa83..9bfbb8e 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ - + ``` + + + + + + + + + diff --git a/test/atomic_check.spec.js b/test/atomic_check.spec.js deleted file mode 100644 index c3f0441..0000000 --- a/test/atomic_check.spec.js +++ /dev/null @@ -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 -}); diff --git a/test/base.test.js b/test/base.test.js deleted file mode 100644 index def575d..0000000 --- a/test/base.test.js +++ /dev/null @@ -1,162 +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(); - - console.log('Testing API declarative preset request fields...'); - LocalStorage.secret = 'api-secret-token'; - const presetApi = document.createElement('API'); - presetApi.setAttribute('$.request.url', "'/echo'"); - presetApi.setAttribute('$.request.method', "'POST'"); - presetApi.setAttribute('$.request.data.secret', "LocalStorage.secret || ''"); - document.body.appendChild(presetApi); - await new Promise(r => setTimeout(r, 50)); - - const originalFetch = globalThis.fetch; - let capturedBody = null; - globalThis.fetch = async (_url, options = {}) => { - capturedBody = options.body; - return new Response(JSON.stringify({ ok: true }), { - status: 200, - headers: { 'Content-Type': 'application/json' } - }); - }; - try { - const presetResp = await presetApi.do(); - if (!presetResp.ok) throw new Error('Declarative API preset request failed'); - } finally { - globalThis.fetch = originalFetch; - presetApi.remove(); - } - if (capturedBody !== JSON.stringify({ secret: 'api-secret-token' })) { - throw new Error('API declarative preset request.data.secret was not preserved'); - } - - // 5. AutoForm & TagsInput Test - console.log('Testing AutoForm...'); - const { NewState } = await import('@apigo.cc/state'); - const form = document.createElement('AutoForm'); - document.body.appendChild(form); - await new Promise(r => setTimeout(r, 200)); - - // Test dynamic schema and visibility - form.data = NewState({ showName: false, name: 'Gemini' }); - form.state.schema = [ - { name: 'tags', type: 'TagsInput', label: '标签' }, - { name: 'showName', type: 'switch', label: 'Show Name' }, - { name: 'name', type: 'text', label: 'Name', if: 'this.data.showName' } - ]; - await new Promise(r => setTimeout(r, 200)); - if (!form.querySelector('TagsInput')) throw new Error('TagsInput not rendered'); - if (form.querySelector('[name="name"]')) throw new Error('Name field should be hidden'); - - form.data.showName = true; - await new Promise(r => setTimeout(r, 200)); - if (!form.querySelector('[name="name"]')) throw new Error('Name field should be visible'); - form.remove(); - - // 6. New Controls (DatePicker, ColorPicker, IconPicker) Test - console.log('Testing New Controls...'); - const controlForm = document.createElement('AutoForm'); - document.body.appendChild(controlForm); - await new Promise(r => setTimeout(r, 200)); - controlForm.state.schema = [ - { name: 'startDate', type: 'DatePicker', setting: { rangeEnd: 'endDate' } }, - { name: 'endDate', type: 'date' }, - { name: 'color', type: 'ColorPicker' }, - { name: 'icon', type: 'IconPicker' } - ]; - controlForm.data = { startDate: '2026-05-01', endDate: '2026-05-31', color: '#ff0000', icon: 'gear' }; - await new Promise(r => setTimeout(r, 400)); - - const dp = controlForm.querySelector('DatePicker'); - const cp = controlForm.querySelector('ColorPicker'); - const ip = controlForm.querySelector('IconPicker'); - - if (!dp) throw new Error('DatePicker not rendered'); - if (!cp) throw new Error('ColorPicker not rendered'); - if (!ip) throw new Error('IconPicker not rendered'); - - // Test DatePicker range sync - dp.updateEnd('2026-06-01'); - if (controlForm.data.endDate !== '2026-06-01') throw new Error('DatePicker rangeEnd sync failed'); - - // Test ColorPicker - cp.updateValue('#00ff00'); - if (controlForm.data.color !== '#00ff00') throw new Error('ColorPicker sync failed'); - - // Test IconPicker - ip.selectIcon('star'); - if (controlForm.data.icon !== 'star') throw new Error('IconPicker sync failed'); - - controlForm.remove(); - - // 7. List Components Basic Verification - console.log('Verifying List Components...'); - const { Component } = await import('@apigo.cc/state'); - console.log('FastList exists:', Component.exists('FastList')); - - const listIds = ['ll', 'gl', 'tt', 'ct']; - for (const id of listIds) { - window.switchTab?.(id); - await new Promise(r => setTimeout(r, 100)); // wait for render - const el = document.getElementById(id); - if (!el) throw new Error(`Component #${id} not found`); - console.log(`Component #${id} tagName:`, el.tagName, 'has refresh:', !!el.refresh, 'setupFunc exists:', !!Component.getSetupFunction(el.tagName)); - if (!el.state.list || el.state.list.length === 0) throw new Error(`Component #${id} list data not bound`); - } - - // 7. Benchmarks - console.log('Recording list benchmarks...'); - window.benchResults = {}; - - const measure = async (id, name) => { - window.switchTab?.(id); - await new Promise(r => setTimeout(r, 100)); // wait for render - const el = document.getElementById(id); - const start = performance.now(); - console.log(`[MEASURE] ${name} setting scrollTop to 5000`); - el.scrollTop = 5000; - console.log(`[MEASURE] ${name} calling refresh`); - el.refresh?.(); - console.log(`[MEASURE] ${name} refresh called, waiting 50ms`); - await new Promise(r => setTimeout(r, 50)); - console.log(`[MEASURE] ${name} wait done`); - const time = performance.now() - start; - window.benchResults[name] = time; - console.log(`BENCHMARK: ${name} scroll & refresh: ${time.toFixed(2)}ms`); - console.log(`DEBUG [${id}]: _renderedList len: ${el.state._renderedList?.length}, _flatList len: ${el.state._flatList?.length}`); - }; - - await measure('ll', 'FastList'); - await measure('gl', 'FastGroupedList'); - await measure('tt', 'FastTree'); - await measure('ct', 'CollapseTree'); - - console.log('All Base.js unit tests completed.'); -} diff --git a/test/capability.spec.js b/test/capability.spec.js new file mode 100644 index 0000000..9f5da4c --- /dev/null +++ b/test/capability.spec.js @@ -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(); +}); diff --git a/test/deep_dump.spec.js b/test/deep_dump.spec.js deleted file mode 100644 index 143848d..0000000 --- a/test/deep_dump.spec.js +++ /dev/null @@ -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); -}); diff --git a/test/form.spec.js b/test/form.spec.js new file mode 100644 index 0000000..b1890f9 --- /dev/null +++ b/test/form.spec.js @@ -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); +}); diff --git a/test/index.html b/test/index.html deleted file mode 100644 index 827c016..0000000 --- a/test/index.html +++ /dev/null @@ -1,116 +0,0 @@ - - - - Base.js Modular & Performance Tests - - - - - - - -
Running tests...
- - - -
- - - - - -
-
-
-
Fast List (Variable Height)
- - - -
- - - - -
- - - - diff --git a/test/lib/base.js b/test/lib/base.js index 6e4b123..4bf3ba5 100644 --- a/test/lib/base.js +++ b/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 @@ -
+
- -
+ +
@@ -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"> - { + var _a; 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 = ((_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 = {}; - list.forEach((item) => { - var _a; - return (itemMap[_a = item[container.groupfield]] ?? (itemMap[_a] = [])).push(item); + 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]]; - if (items) items.forEach((item) => flatList.push({ type: "item", ...item })); + 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*/ ` -
+
+
-
- +
+
+ +
` )); diff --git a/test/lib/bootstrap.js b/test/lib/bootstrap.js index a42f140..8e63494 100644 --- a/test/lib/bootstrap.js +++ b/test/lib/bootstrap.js @@ -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; diff --git a/test/lib/state.js b/test/lib/state.js index e6e0eec..40c0b53 100644 --- a/test/lib/state.js +++ b/test/lib/state.js @@ -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); diff --git a/test/list.spec.js b/test/list.spec.js new file mode 100644 index 0000000..7bb337f --- /dev/null +++ b/test/list.spec.js @@ -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(); +}); + diff --git a/test/list_test.html b/test/list_test.html index aa461e5..52f0105 100644 --- a/test/list_test.html +++ b/test/list_test.html @@ -16,16 +16,32 @@
-
1. Standard List (Normal)
- +
+ 1. Standard List (Normal) +
+ + +
+
+
-
2. Group List (Mode: Group)
- +
+ 2. Group List (Mode: Group) + +
+
-
3. Tree List (Mode: Tree + Collapsible)
- +
+ 3. Tree List (Mode: Tree + Collapsible) + +
+
4. FAST Virtual List (10,000 Items + Dynamic Height)
diff --git a/test/mega_verify.spec.js b/test/mega_verify.spec.js deleted file mode 100644 index 7c312e1..0000000 --- a/test/mega_verify.spec.js +++ /dev/null @@ -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); -}); diff --git a/test/verify_stress.spec.js b/test/verify_stress.spec.js deleted file mode 100644 index 3f26777..0000000 --- a/test/verify_stress.spec.js +++ /dev/null @@ -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'); -}); diff --git a/test/verify_style.spec.js b/test/verify_style.spec.js deleted file mode 100644 index 55a6b81..0000000 --- a/test/verify_style.spec.js +++ /dev/null @@ -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); - } -});