@opentiny/next-sdk 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{SimulatorMask-BHVXyogh-iNUb4dyF.js → SimulatorMask-BHVXyogh-CARX3Rff.js} +1 -1
- package/dist/core.js +1 -1
- package/dist/{index-CXK8CcKq.js → index-R_HIbfUX.js} +1698 -1170
- package/dist/index.d.ts +11 -0
- package/dist/index.js +69 -50
- package/dist/{initialize-builtin-WebMCP-rALfqS-o.js → initialize-builtin-WebMCP-JaoKwVlm.js} +355 -346
- package/dist/page-tools/a11y/build.d.ts +3 -4
- package/dist/page-tools/a11y/config.d.ts +96 -0
- package/dist/page-tools/a11y/constants.d.ts +2 -0
- package/dist/page-tools/a11y/search.d.ts +1 -1
- package/dist/page-tools/a11y/types.d.ts +13 -22
- package/dist/page-tools/a11y/utils.d.ts +32 -11
- package/dist/page-tools/a11y/vnode.d.ts +14 -11
- package/dist/page-tools/a11y-tree.d.ts +1 -0
- package/dist/page-tools/configs/console-cloud.d.ts +6 -0
- package/dist/page-tools/constants.d.ts +5 -17
- package/dist/page-tools/context.d.ts +12 -0
- package/dist/page-tools/handlers/searchTree.d.ts +2 -1
- package/dist/page-tools/page-agent-highlight/index.d.ts +12 -0
- package/dist/page-tools/page-agent-mask/SimulatorMask.d.ts +2 -0
- package/dist/page-tools/page-agent-tool-event.d.ts +26 -0
- package/dist/page-tools/schema.d.ts +1 -0
- package/dist/page-tools/tool-config.d.ts +50 -0
- package/dist/runtime.d.ts +4 -1
- package/dist/runtime.js +37 -49
- package/dist/vitest.config.d.ts +2 -0
- package/index.ts +35 -0
- package/package.json +21 -16
- package/page-tools/a11y/build.ts +38 -34
- package/page-tools/a11y/config.ts +465 -0
- package/page-tools/a11y/constants.ts +21 -0
- package/page-tools/a11y/search.ts +1 -3
- package/page-tools/a11y/types.ts +14 -17
- package/page-tools/a11y/utils.ts +129 -156
- package/page-tools/a11y/vnode.ts +277 -67
- package/page-tools/a11y-tree.ts +1 -0
- package/page-tools/bridge.ts +22 -2
- package/page-tools/configs/console-cloud.ts +172 -0
- package/page-tools/constants.ts +5 -65
- package/page-tools/context.ts +38 -1
- package/page-tools/handlers/click.ts +6 -2
- package/page-tools/handlers/fill.ts +10 -5
- package/page-tools/handlers/searchTree.ts +7 -10
- package/page-tools/handlers/select.ts +10 -5
- package/page-tools/page-agent-highlight/index.ts +66 -19
- package/page-tools/page-agent-mask/SimulatorMask.ts +42 -22
- package/page-tools/page-agent-mask/hauwei.svg +25 -0
- package/page-tools/page-agent-prompt.md +1 -1
- package/page-tools/page-agent-tool-event.ts +113 -0
- package/page-tools/page-agent-tool.ts +85 -53
- package/page-tools/schema.ts +1 -0
- package/page-tools/tool-config.ts +100 -0
- package/page-tools/utils/dom.ts +6 -3
- package/runtime.ts +37 -6
- package/test/page-tools/a11y/build.test.ts +638 -0
- package/test/page-tools/a11y/config.test.ts +370 -0
- package/test/page-tools/configs/console-cloud.test.ts +168 -0
- package/test/page-tools/page-agent-highlight.test.ts +110 -0
- package/test/page-tools/page-agent-tool-dispatch.test.ts +208 -0
- package/test/page-tools/page-agent-tool.test.ts +102 -0
- package/test/page-tools/tool-config.test.ts +112 -0
- package/test/page-tools/utils/dom.test.ts +122 -0
- package/vite.config.runtime.ts +1 -1
- package/vitest.config.ts +10 -0
|
@@ -25,6 +25,27 @@ export const DEFAULT_WARNING_SELECTORS = [
|
|
|
25
25
|
'.warning-msg', '.warning-text', '.is-warning', '.has-warning',
|
|
26
26
|
]
|
|
27
27
|
|
|
28
|
+
/** 模态弹窗默认选择器:ARIA 标准 + 主流 UI 框架(唯一来源,顶层 constants.ts 从此处重新导出) */
|
|
29
|
+
export const DEFAULT_DIALOG_SELECTORS = [
|
|
30
|
+
// W3C ARIA 标准
|
|
31
|
+
'[role="dialog"]',
|
|
32
|
+
'[role="alertdialog"]',
|
|
33
|
+
// Tiny3 / Lego(云控制台)
|
|
34
|
+
'[class*="ti3-modal"]',
|
|
35
|
+
'[class*="ti3-message-box"]',
|
|
36
|
+
// Element UI / Element Plus
|
|
37
|
+
'[class*="el-dialog"]',
|
|
38
|
+
'[class*="el-message-box"]',
|
|
39
|
+
// Ant Design
|
|
40
|
+
'[class*="ant-modal"]',
|
|
41
|
+
// Bootstrap
|
|
42
|
+
'[class*="modal-content"]',
|
|
43
|
+
// Vuetify
|
|
44
|
+
'[class*="v-dialog"]',
|
|
45
|
+
// Naive UI
|
|
46
|
+
'[class*="n-modal"]',
|
|
47
|
+
]
|
|
48
|
+
|
|
28
49
|
// ─── ARIA 隐式角色静态映射表(覆盖页面 95%+ 的常用标签)───────────────────────
|
|
29
50
|
|
|
30
51
|
export const TAG_ROLE_MAP: Record<string, string> = {
|
|
@@ -24,8 +24,6 @@ import { buildA11yTree } from './build'
|
|
|
24
24
|
export function searchA11yTree(
|
|
25
25
|
query: string,
|
|
26
26
|
root: Element = document.body,
|
|
27
|
-
blacklist: Element[] = [],
|
|
28
|
-
whitelist: Element[] = [],
|
|
29
27
|
options?: SearchA11yTreeOptions,
|
|
30
28
|
): SearchA11yTreeResult {
|
|
31
29
|
const {
|
|
@@ -39,7 +37,7 @@ export function searchA11yTree(
|
|
|
39
37
|
const safeMaxMatches = Math.max(1, maxMatches)
|
|
40
38
|
|
|
41
39
|
// 复用 buildA11yTree 生成完整树,直接取 lines 数组(不重复构建 DOM 遍历)
|
|
42
|
-
const { lines, refMap, yaml } = buildA11yTree(root,
|
|
40
|
+
const { lines, refMap, yaml } = buildA11yTree(root, treeOptions)
|
|
43
41
|
|
|
44
42
|
const needle = caseInsensitive ? query.toLowerCase() : query
|
|
45
43
|
const totalLines = lines.length
|
package/page-tools/a11y/types.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* 定义无障碍树模块(A11y Tree)的核心接口和类型。
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import type { A11yConfig } from './config'
|
|
8
|
+
|
|
7
9
|
/** ref 索引 → HTMLElement 映射,供 click/fill/select 操作使用 */
|
|
8
10
|
export type RefMap = Map<number, HTMLElement>
|
|
9
11
|
|
|
@@ -23,8 +25,12 @@ export interface VNode {
|
|
|
23
25
|
children: VNode[]
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
/**
|
|
27
|
-
|
|
28
|
+
/**
|
|
29
|
+
* 构建无障碍树时的配置选项。
|
|
30
|
+
* 继承统一的 A11yConfig(角色/状态规则、白/黑名单、自定义暴露属性、弹窗选择器),
|
|
31
|
+
* 并额外提供两个控制树"形状"(而非无障碍语义)的选项。
|
|
32
|
+
*/
|
|
33
|
+
export interface A11yTreeOptions extends A11yConfig {
|
|
28
34
|
/**
|
|
29
35
|
* 是否启用剪枝:无 ref 且无 accessible name 的节点透明穿透
|
|
30
36
|
* 默认 true(推荐)
|
|
@@ -35,21 +41,12 @@ export interface A11yTreeOptions {
|
|
|
35
41
|
* 例如:['table', 'row'] 用于保留表格结构
|
|
36
42
|
*/
|
|
37
43
|
preserveRoles?: string[]
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* 校验错误元素 CSS 选择器(逗号分隔或选择器数组),用于在 token 中标记 [error]
|
|
46
|
-
* 默认覆盖 ARIA 标准 + 主流 UI 框架
|
|
47
|
-
*/
|
|
48
|
-
errorSelectors?: string | string[]
|
|
49
|
-
/**
|
|
50
|
-
* 校验警告元素 CSS 选择器(逗号分隔或选择器数组)
|
|
51
|
-
*/
|
|
52
|
-
warningSelectors?: string | string[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 序列化 YAML 树时实际用到的形状选项(与无障碍语义解析无关,独立于 A11yConfig) */
|
|
47
|
+
export interface A11yTreeShapeOptions {
|
|
48
|
+
pruneUnnamed: boolean
|
|
49
|
+
preserveRoles: string[]
|
|
53
50
|
}
|
|
54
51
|
|
|
55
52
|
/** 构建无障碍树的返回结果 */
|
package/page-tools/a11y/utils.ts
CHANGED
|
@@ -1,181 +1,137 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* a11y/utils.ts
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 存放生成无障碍树时的通用工具函数:可见性判断、纯文本兜底、Shadow DOM 组合树遍历等。
|
|
5
|
+
* 角色推断与状态 token 解析已统一迁移到 ./config(resolveA11yRole/resolveA11yStates/resolveA11yInfo)。
|
|
5
6
|
*/
|
|
6
7
|
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
8
|
+
import { isTabbable } from 'tabbable'
|
|
9
|
+
import { resolveA11yRole, type ResolvedA11yConfig } from './config'
|
|
10
|
+
import { deepQuerySelectorAll } from '../utils/dom'
|
|
9
11
|
|
|
10
12
|
/**
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
+
* 非内容元素:不应进入无障碍树,其内部文本也不得被 AccName / textContent 兜底吸收。
|
|
14
|
+
* 云控制台常见在 body 内联 `<style>@font-face { src: url(data:font/...;base64,...) }</style>`,
|
|
15
|
+
* 若不剔除,整段 base64 会变成 generic 的 name,严重浪费 LLM token。
|
|
13
16
|
*/
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
const NON_CONTENT_TAGS = new Set([
|
|
18
|
+
'script',
|
|
19
|
+
'style',
|
|
20
|
+
'noscript',
|
|
21
|
+
'template',
|
|
22
|
+
'link',
|
|
23
|
+
'meta',
|
|
24
|
+
'head',
|
|
25
|
+
'title',
|
|
26
|
+
])
|
|
27
|
+
|
|
28
|
+
export function isNonContentElement(el: Element): boolean {
|
|
29
|
+
return NON_CONTENT_TAGS.has(el.tagName.toLowerCase())
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
33
|
+
* 判断元素是否"自身"呈现 cursor:pointer(而非从祖先继承而来)。
|
|
34
|
+
*
|
|
35
|
+
* 背景:CSS 的 `cursor` 属性会向子孙继承。可点击容器(如 <a>、自定义卡片 .card-wrapper)
|
|
36
|
+
* 一旦设置 cursor:pointer,其内部所有子孙的 computed cursor 都会变成 pointer。
|
|
37
|
+
* 若仅凭「computed cursor === pointer」判定交互性,会让容器的可点击性"传染"给全部子孙,
|
|
38
|
+
* 产生大量误报 ref(父容器 cursor:pointer 泛滥到每个子节点)。
|
|
39
|
+
*
|
|
40
|
+
* 业界主流 DOM 提取器(如 browser-use 的 doesElementHaveInteractivePointer)同样以
|
|
41
|
+
* cursor:pointer 作为可点击性的核心兜底信号,并通过与父元素对比来定位真正的可点击"边界元素"。
|
|
42
|
+
* 因此这里的判定为:元素自身 computed cursor 为 pointer,且其父元素不是 pointer —— 说明
|
|
43
|
+
* 该元素(或命中它的 CSS 规则)主动声明了指针手势,是真正意义上的可点击目标。
|
|
44
|
+
*
|
|
45
|
+
* 无父元素(如 shadow host 边界、游离节点)时,没有可继承来源,视为自身声明。
|
|
30
46
|
*/
|
|
31
|
-
export function
|
|
32
|
-
el: Element,
|
|
33
|
-
exposedAttributes?: string[],
|
|
34
|
-
errorSelectors?: string | string[],
|
|
35
|
-
warningSelectors?: string | string[],
|
|
36
|
-
): string[] {
|
|
37
|
-
const tokens: string[] = []
|
|
38
|
-
const aria = (k: string) => el.getAttribute(k)
|
|
39
|
-
|
|
40
|
-
// 选中状态:优先 aria-checked,其次原生 input.checked
|
|
41
|
-
// 对 <label for="X"> 检测关联 checkbox/radio 的选中状态,帮助 AI 识别复选框当前状态
|
|
42
|
-
const ariaChecked = aria('aria-checked')
|
|
43
|
-
if (ariaChecked === 'true') {
|
|
44
|
-
tokens.push('checked')
|
|
45
|
-
} else if (ariaChecked === 'false') {
|
|
46
|
-
tokens.push('unchecked')
|
|
47
|
-
} else {
|
|
48
|
-
const elTag = el.tagName.toLowerCase()
|
|
49
|
-
let nativeChecked: boolean | undefined
|
|
50
|
-
if (elTag === 'input' && ((el as HTMLInputElement).type === 'checkbox' || (el as HTMLInputElement).type === 'radio')) {
|
|
51
|
-
nativeChecked = (el as HTMLInputElement).checked
|
|
52
|
-
} else if (elTag === 'label' && el.hasAttribute('for')) {
|
|
53
|
-
const target = document.getElementById(el.getAttribute('for')!)
|
|
54
|
-
if (target instanceof HTMLInputElement && (target.type === 'checkbox' || target.type === 'radio')) {
|
|
55
|
-
nativeChecked = target.checked
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
if (nativeChecked !== undefined) {
|
|
59
|
-
tokens.push(nativeChecked ? 'checked' : 'unchecked')
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
if (aria('aria-selected') === 'true') tokens.push('selected')
|
|
64
|
-
|
|
65
|
-
const disabled = aria('aria-disabled') === 'true' || (el as HTMLInputElement).disabled
|
|
66
|
-
if (disabled) tokens.push('disabled')
|
|
67
|
-
|
|
68
|
-
const hasPopup = aria('aria-haspopup')
|
|
69
|
-
if (hasPopup && hasPopup !== 'false') tokens.push('hasPopup')
|
|
70
|
-
|
|
71
|
-
if (aria('aria-expanded') === 'true') tokens.push('expanded')
|
|
72
|
-
|
|
73
|
-
// heading level(h1-h6)
|
|
74
|
-
const headingMatch = el.tagName.match(/^H([1-6])$/)
|
|
75
|
-
if (headingMatch) tokens.push(`level=${headingMatch[1]}`)
|
|
76
|
-
const ariaLevel = aria('aria-level')
|
|
77
|
-
if (ariaLevel && !headingMatch) tokens.push(`level=${ariaLevel}`)
|
|
78
|
-
|
|
79
|
-
// cursor=pointer 表示"视觉上可点击"
|
|
47
|
+
export function hasOwnPointerCursor(el: Element): boolean {
|
|
80
48
|
try {
|
|
81
|
-
|
|
82
|
-
|
|
49
|
+
if (window.getComputedStyle(el as HTMLElement).cursor !== 'pointer') return false
|
|
50
|
+
const parent = el.parentElement
|
|
51
|
+
if (!parent) return true
|
|
52
|
+
return window.getComputedStyle(parent).cursor !== 'pointer'
|
|
83
53
|
} catch {
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
// 记录输入元素的值,以便在 fill/输入后在 A11y 树中显示并产生 Diff
|
|
88
|
-
const tag = el.tagName.toLowerCase()
|
|
89
|
-
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
|
|
90
|
-
const val = (el as HTMLInputElement).value
|
|
91
|
-
if (val !== undefined && val !== '') {
|
|
92
|
-
tokens.push(`value="${val}"`)
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
const valuenow = el.getAttribute('aria-valuenow')
|
|
96
|
-
if (valuenow) {
|
|
97
|
-
tokens.push(`valuenow="${valuenow}"`)
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// link 元素:检测 target=_blank,提示 Agent 该链接会在新标签页打开
|
|
101
|
-
if (el.tagName.toLowerCase() === 'a' && el.getAttribute('target') === '_blank') {
|
|
102
|
-
tokens.push('opens-new-tab')
|
|
54
|
+
// 忽略跨域 iframe 等无法访问 style 的场景
|
|
55
|
+
return false
|
|
103
56
|
}
|
|
57
|
+
}
|
|
104
58
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const cls = typeof el.className === 'string' ? el.className : ''
|
|
108
|
-
const ACTIVE_CLASS_PATTERNS = [
|
|
109
|
-
'is-active', 'isActive',
|
|
110
|
-
'is-selected', 'isSelected',
|
|
111
|
-
'is-current', 'isCurrent',
|
|
112
|
-
'active-item', 'activeItem',
|
|
113
|
-
'tab-active', 'tabActive',
|
|
114
|
-
// 仅当作为独立 class 词或有连字符前缀时匹配 "active",避免误匹配 "interactive" 等
|
|
115
|
-
/\bactive\b/,
|
|
116
|
-
/\bselected\b/,
|
|
117
|
-
/\bcurrent\b/,
|
|
118
|
-
]
|
|
119
|
-
const hasActiveClass = ACTIVE_CLASS_PATTERNS.some(p =>
|
|
120
|
-
typeof p === 'string' ? cls.split(/\s+/).includes(p) : p.test(cls)
|
|
121
|
-
)
|
|
122
|
-
// 只对有 cursor=pointer 或明确角色的元素输出 active token,避免太多噪音
|
|
123
|
-
const roleForActive = el.getAttribute('role') || el.tagName.toLowerCase()
|
|
124
|
-
const roles = roleForActive.split(/\s+/)
|
|
125
|
-
const isTabLike = roles.some(r => ['button', 'option', 'a', 'li', 'generic'].includes(r) || r.startsWith('tab'))
|
|
126
|
-
if (hasActiveClass && isTabLike && !tokens.includes('checked') && !tokens.includes('selected')) {
|
|
127
|
-
tokens.push('active')
|
|
128
|
-
}
|
|
59
|
+
/** 交互态伪类:cursor 常只在这些状态下声明为 pointer(如卡片 hover 时才显示手势) */
|
|
60
|
+
const INTERACTION_PSEUDO_RE = /:(hover|focus|focus-visible|focus-within|active)\b/gi
|
|
129
61
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
62
|
+
/**
|
|
63
|
+
* 递归遍历样式表规则(含 @media / @supports 等分组规则),收集在交互伪类下声明
|
|
64
|
+
* cursor:pointer 的选择器,并去掉伪类得到"主体选择器"。
|
|
65
|
+
*/
|
|
66
|
+
function collectInteractivePointerSelectors(rules: CSSRuleList, out: Set<string>): void {
|
|
67
|
+
for (const rule of Array.from(rules)) {
|
|
68
|
+
const styleRule = rule as CSSStyleRule
|
|
69
|
+
const grouping = rule as CSSGroupingRule
|
|
70
|
+
// @media / @supports 等分组规则:自身无 selectorText,递归其子规则
|
|
71
|
+
if (!styleRule.selectorText && grouping.cssRules) {
|
|
72
|
+
collectInteractivePointerSelectors(grouping.cssRules, out)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
const sel = styleRule.selectorText
|
|
76
|
+
if (!sel || !styleRule.style || styleRule.style.cursor !== 'pointer') continue
|
|
77
|
+
if (!/:(hover|focus|focus-visible|focus-within|active)\b/i.test(sel)) continue
|
|
78
|
+
for (const part of sel.split(',')) {
|
|
79
|
+
const base = part.replace(INTERACTION_PSEUDO_RE, '').trim()
|
|
80
|
+
// 去掉伪类后可能为空(如 `:hover`)或过宽(`*`),跳过以免误伤全页
|
|
81
|
+
if (base && base !== '*') out.add(base)
|
|
145
82
|
}
|
|
146
83
|
}
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 收集页面中"仅在 :hover/:focus/:active 等交互态下声明 cursor:pointer"的元素集合。
|
|
88
|
+
*
|
|
89
|
+
* 背景(华为云控制台实测):大量可点击卡片(如 `.container-wrapper .shadow:hover{cursor:pointer}`)
|
|
90
|
+
* 只在鼠标悬停时才显示手势,静止态 `getComputedStyle(el).cursor` 读到的是 `auto`,
|
|
91
|
+
* 因此仅凭 computed cursor 的判定(含业界 getComputedStyle 方案)会漏判这类元素。
|
|
92
|
+
* 这里通过扫描样式表把这类"交互态手势"补齐,作为可点击性的兜底信号。
|
|
93
|
+
*
|
|
94
|
+
* 每次构建树只调用一次(O(规则数) 扫描 + 一次合并查询),避免逐元素扫描样式表。
|
|
95
|
+
*/
|
|
96
|
+
export function collectHoverPointerElements(root: Element): Set<Element> {
|
|
97
|
+
const set = new Set<Element>()
|
|
98
|
+
const selectorSet = new Set<string>()
|
|
99
|
+
try {
|
|
100
|
+
for (const sheet of Array.from(document.styleSheets)) {
|
|
101
|
+
let rules: CSSRuleList | null = null
|
|
153
102
|
try {
|
|
154
|
-
|
|
103
|
+
rules = sheet.cssRules
|
|
155
104
|
} catch {
|
|
156
|
-
//
|
|
105
|
+
// 跨域样式表无法访问 cssRules,跳过
|
|
106
|
+
continue
|
|
157
107
|
}
|
|
108
|
+
if (rules) collectInteractivePointerSelectors(rules, selectorSet)
|
|
158
109
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
110
|
+
} catch {
|
|
111
|
+
return set
|
|
162
112
|
}
|
|
113
|
+
if (selectorSet.size === 0) return set
|
|
163
114
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
115
|
+
const selectors = Array.from(selectorSet)
|
|
116
|
+
// 优先合并为单条选择器一次性查询;若含非法片段导致整体失败,退化为逐条查询保证鲁棒
|
|
117
|
+
try {
|
|
118
|
+
for (const el of deepQuerySelectorAll(selectors.join(', '), root)) set.add(el)
|
|
119
|
+
return set
|
|
120
|
+
} catch {
|
|
121
|
+
for (const sel of selectors) {
|
|
122
|
+
try {
|
|
123
|
+
for (const el of deepQuerySelectorAll(sel, root)) set.add(el)
|
|
124
|
+
} catch {
|
|
125
|
+
// 忽略非法选择器
|
|
170
126
|
}
|
|
171
127
|
}
|
|
172
128
|
}
|
|
173
|
-
|
|
174
|
-
return tokens
|
|
129
|
+
return set
|
|
175
130
|
}
|
|
176
131
|
|
|
177
|
-
/**
|
|
132
|
+
/** 判断元素是否应被跳过(不可见、非内容或在黑名单中) */
|
|
178
133
|
export function isHidden(el: Element): boolean {
|
|
134
|
+
if (isNonContentElement(el)) return true
|
|
179
135
|
if (el.getAttribute('aria-hidden') === 'true') return true
|
|
180
136
|
if ((el as HTMLElement).hidden) return true
|
|
181
137
|
try {
|
|
@@ -196,32 +152,49 @@ export function isHidden(el: Element): boolean {
|
|
|
196
152
|
}
|
|
197
153
|
|
|
198
154
|
|
|
155
|
+
/**
|
|
156
|
+
* 收集元素自身或子孙节点的 title,用作图标按钮等无文本节点的名字兜底。
|
|
157
|
+
* 云控制台常见模式:可点击容器无 aria-label,title 挂在内部 span 上。
|
|
158
|
+
*/
|
|
159
|
+
export function collectTitleLabel(el: Element): string {
|
|
160
|
+
const selfTitle = el.getAttribute('title')?.trim()
|
|
161
|
+
if (selfTitle) return selfTitle
|
|
162
|
+
|
|
163
|
+
const titled = el.querySelector('[title]')
|
|
164
|
+
const childTitle = titled?.getAttribute('title')?.trim()
|
|
165
|
+
if (childTitle) return childTitle
|
|
166
|
+
|
|
167
|
+
return ''
|
|
168
|
+
}
|
|
169
|
+
|
|
199
170
|
/**
|
|
200
171
|
* 收集子孙节点的文本内容,用作无障碍名字的兜底。
|
|
201
172
|
* 当普通计算无法提取文本时,遍历后代并拼接可见文本。
|
|
173
|
+
* @param config 已规整的无障碍配置;传入后角色判断会尊重页面自定义 roles 规则
|
|
202
174
|
*/
|
|
203
|
-
export function collectDescendantText(el: Element): string {
|
|
175
|
+
export function collectDescendantText(el: Element, config?: ResolvedA11yConfig): string {
|
|
204
176
|
let text = ''
|
|
205
177
|
const walk = (node: Node) => {
|
|
206
178
|
if (node.nodeType === Node.TEXT_NODE) {
|
|
207
179
|
text += (node.textContent ?? '') + ' '
|
|
208
180
|
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
209
181
|
const element = node as Element
|
|
210
|
-
|
|
182
|
+
// 根节点若是 style/script 则整段丢弃;子孙遇到非内容标签则跳过子树
|
|
183
|
+
if (element !== el && isNonContentElement(element)) return
|
|
184
|
+
if (isHidden(element) && element !== el) return
|
|
185
|
+
// 根为非内容元素时不收集(防御)
|
|
186
|
+
if (element === el && isNonContentElement(element)) return
|
|
211
187
|
|
|
212
188
|
// 如果遇到嵌套的列表项或其他交互/语义节点,停止向下遍历该子树,避免兜底文本重复吸收
|
|
213
189
|
if (element !== el) {
|
|
214
190
|
const tag = element.tagName.toLowerCase()
|
|
215
|
-
const role =
|
|
191
|
+
const role = resolveA11yRole(element, config)
|
|
216
192
|
const isInteractiveTag = ['button', 'a', 'input', 'select', 'textarea', 'li', 'option'].includes(tag)
|
|
217
193
|
const isInteractiveRole = ['button', 'link', 'checkbox', 'radio', 'textbox', 'listitem', 'option', 'combobox', 'listbox'].includes(role)
|
|
218
194
|
const isTrulyInteractive = isTabbable(element as HTMLElement)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
} catch { /* ignore */ }
|
|
223
|
-
const isMeaningfullyInteractive = isTrulyInteractive && !(role === 'generic' && !isCursorPointer)
|
|
224
|
-
|
|
195
|
+
const isOwnPointer = hasOwnPointerCursor(element)
|
|
196
|
+
const isMeaningfullyInteractive = isTrulyInteractive && !(role === 'generic' && !isOwnPointer)
|
|
197
|
+
|
|
225
198
|
if (isMeaningfullyInteractive || isInteractiveTag || isInteractiveRole) {
|
|
226
199
|
return
|
|
227
200
|
}
|