@opentiny/next-sdk 0.4.0 → 0.4.1
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/agent/AgentModelProvider.ts +78 -81
- package/agent/type.ts +6 -9
- package/agent/utils/getAISDKTools.ts +0 -1
- package/agent/utils/getBuiltinMcpTools.ts +7 -7
- package/core.ts +0 -3
- package/dist/SimulatorMask-BHVXyogh-iNUb4dyF.js +361 -0
- package/dist/agent/type.d.ts +4 -12
- package/dist/agent/utils/getBuiltinMcpTools.d.ts +3 -3
- package/dist/core.d.ts +0 -1
- package/dist/core.js +16 -17
- package/dist/index-CXK8CcKq.js +6076 -0
- package/dist/index.d.ts +0 -3
- package/dist/index.js +57 -4969
- package/dist/{initialize-builtin-WebMCP-HgObT902.js → initialize-builtin-WebMCP-rALfqS-o.js} +1166 -1056
- package/dist/page-tools/a11y/build.d.ts +11 -0
- package/dist/page-tools/a11y/constants.d.ts +9 -0
- package/dist/page-tools/a11y/search.d.ts +17 -0
- package/dist/page-tools/a11y/types.d.ts +104 -0
- package/dist/page-tools/a11y/utils.d.ts +34 -0
- package/dist/page-tools/a11y/vnode.d.ts +37 -0
- package/dist/page-tools/a11y-tree.d.ts +8 -99
- package/dist/page-tools/constants.d.ts +22 -0
- package/dist/page-tools/context.d.ts +28 -0
- package/dist/page-tools/handlers/browserState.d.ts +8 -0
- package/dist/page-tools/handlers/click.d.ts +8 -0
- package/dist/page-tools/handlers/executeJavascript.d.ts +8 -0
- package/dist/page-tools/handlers/fill.d.ts +8 -0
- package/dist/page-tools/handlers/scroll.d.ts +8 -0
- package/dist/page-tools/handlers/searchTree.d.ts +8 -0
- package/dist/page-tools/handlers/select.d.ts +8 -0
- package/dist/page-tools/page-agent-highlight/index.d.ts +9 -0
- package/dist/page-tools/page-agent-mask/SimulatorMask.d.ts +14 -0
- package/dist/page-tools/page-agent-mask/checkDarkMode.d.ts +5 -0
- package/dist/page-tools/page-agent-tool.d.ts +3 -8
- package/dist/page-tools/schema.d.ts +43 -0
- package/dist/page-tools/utils/dom.d.ts +6 -0
- package/dist/page-tools/utils/scroll.d.ts +15 -0
- package/dist/runtime.d.ts +4 -0
- package/dist/runtime.js +744 -0
- package/dist/utils/builtinProxy.d.ts +1 -1
- package/index.ts +0 -5
- package/package.json +6 -17
- package/page-tools/a11y/build.ts +70 -0
- package/page-tools/a11y/constants.ts +110 -0
- package/page-tools/a11y/search.ts +129 -0
- package/page-tools/a11y/types.ts +108 -0
- package/page-tools/a11y/utils.ts +266 -0
- package/page-tools/a11y/vnode.ts +229 -0
- package/page-tools/a11y-tree.ts +8 -527
- package/page-tools/bridge.ts +1 -1
- package/page-tools/constants.ts +72 -0
- package/page-tools/context.ts +13 -0
- package/page-tools/handlers/browserState.ts +12 -0
- package/page-tools/handlers/click.ts +26 -0
- package/page-tools/handlers/executeJavascript.ts +22 -0
- package/page-tools/handlers/fill.ts +60 -0
- package/page-tools/handlers/scroll.ts +66 -0
- package/page-tools/handlers/searchTree.ts +30 -0
- package/page-tools/handlers/select.ts +34 -0
- package/page-tools/page-agent-highlight/index.ts +198 -0
- package/page-tools/page-agent-mask/SimulatorMask.module.css +14 -0
- package/page-tools/page-agent-mask/SimulatorMask.ts +279 -0
- package/page-tools/page-agent-mask/checkDarkMode.ts +181 -0
- package/page-tools/page-agent-mask/cursor-border.svg +3 -0
- package/page-tools/page-agent-mask/cursor-fill.svg +5 -0
- package/page-tools/page-agent-mask/cursor.module.css +70 -0
- package/page-tools/page-agent-prompt.md +34 -18
- package/page-tools/page-agent-tool.ts +104 -152
- package/page-tools/schema.ts +51 -0
- package/page-tools/utils/dom.ts +155 -0
- package/page-tools/utils/scroll.ts +58 -0
- package/runtime.ts +13 -0
- package/utils/builtinProxy.ts +45 -13
- package/vite.config.runtime.ts +22 -0
- package/vite.config.ts +52 -8
- package/McpSdk.ts +0 -14
- package/WebAgent.ts +0 -5
- package/WebMcp.ts +0 -26
- package/Zod.ts +0 -1
- package/dist/McpSdk.d.ts +0 -14
- package/dist/SimulatorMask-BHVXyogh-BFEGpD5S.js +0 -1048
- package/dist/SimulatorMask-BHVXyogh-CCYbrb84.js +0 -801
- package/dist/WebAgent.d.ts +0 -5
- package/dist/WebMcp.d.ts +0 -23
- package/dist/Zod.d.ts +0 -1
- package/dist/index.es.dev.js +0 -59017
- package/dist/index.es.js +0 -46795
- package/dist/index.umd.dev.js +0 -60355
- package/dist/index.umd.js +0 -1248
- package/dist/mcpsdk@1.25.3.dev.js +0 -22780
- package/dist/mcpsdk@1.25.3.es.dev.js +0 -22778
- package/dist/mcpsdk@1.25.3.es.js +0 -16960
- package/dist/mcpsdk@1.25.3.js +0 -48
- package/dist/transport/ExtensionClientTransport.d.ts +0 -24
- package/dist/transport/ExtensionContentServerTransport.d.ts +0 -39
- package/dist/transport/ExtensionPageServerTransport.d.ts +0 -36
- package/dist/transport/messages.d.ts +0 -9
- package/dist/vite.config.mcpSdk.d.ts +0 -2
- package/dist/vite.config.webAgent.d.ts +0 -2
- package/dist/vite.config.webMcp.d.ts +0 -2
- package/dist/vite.config.webMcpFull.d.ts +0 -2
- package/dist/vite.config.zod.d.ts +0 -2
- package/dist/webagent.dev.js +0 -49360
- package/dist/webagent.es.dev.js +0 -49071
- package/dist/webagent.es.js +0 -39219
- package/dist/webagent.js +0 -642
- package/dist/webmcp-full.dev.js +0 -31336
- package/dist/webmcp-full.es.dev.js +0 -30283
- package/dist/webmcp-full.es.js +0 -22889
- package/dist/webmcp-full.js +0 -645
- package/dist/webmcp.dev.js +0 -9572
- package/dist/webmcp.es.dev.js +0 -8518
- package/dist/webmcp.es.js +0 -6727
- package/dist/webmcp.js +0 -602
- package/dist/zod@3.25.76.dev.js +0 -4037
- package/dist/zod@3.25.76.es.dev.js +0 -4033
- package/dist/zod@3.25.76.es.js +0 -2945
- package/dist/zod@3.25.76.js +0 -1
- package/transport/ExtensionClientTransport.ts +0 -100
- package/transport/ExtensionContentServerTransport.ts +0 -162
- package/transport/ExtensionPageServerTransport.ts +0 -149
- package/transport/messages.ts +0 -63
- package/vite-build-tsc.ts +0 -63
- package/vite-env.d.ts +0 -10
- package/vite.config.mcpSdk.ts +0 -28
- package/vite.config.webAgent.ts +0 -19
- package/vite.config.webMcp.ts +0 -40
- package/vite.config.webMcpFull.ts +0 -19
- package/vite.config.zod.ts +0 -23
- /package/dist/{vite-build-tsc.d.ts → vite.config.runtime.d.ts} +0 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a11y/utils.ts
|
|
3
|
+
*
|
|
4
|
+
* 存放生成无障碍树时的通用工具函数:节点解析、状态获取、角色推断及纯文本兜底等。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { isFocusable, isTabbable } from 'tabbable'
|
|
8
|
+
import { TAG_ROLE_MAP, INPUT_TYPE_ROLE, DEFAULT_ERROR_SELECTORS, DEFAULT_WARNING_SELECTORS } from './constants'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 获取元素的 ARIA 角色
|
|
12
|
+
* 优先级:显式 role 属性 > 标签隐式角色 > 'generic'
|
|
13
|
+
*/
|
|
14
|
+
export function inferRole(el: Element): string {
|
|
15
|
+
const explicit = el.getAttribute('role')
|
|
16
|
+
if (explicit && explicit !== 'presentation' && explicit !== 'none') {
|
|
17
|
+
return explicit
|
|
18
|
+
}
|
|
19
|
+
const tag = el.tagName.toLowerCase()
|
|
20
|
+
if (tag === 'input') {
|
|
21
|
+
const inputType = (el as HTMLInputElement).type?.toLowerCase() ?? 'text'
|
|
22
|
+
return INPUT_TYPE_ROLE[inputType] ?? 'textbox'
|
|
23
|
+
}
|
|
24
|
+
return TAG_ROLE_MAP[tag] ?? 'generic'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 收集节点的 ARIA 状态 token
|
|
29
|
+
* 格式:[checked] [selected] [disabled] [hasPopup] [cursor=pointer] [value="..."]
|
|
30
|
+
*/
|
|
31
|
+
export function getStateTokens(
|
|
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 表示"视觉上可点击"
|
|
80
|
+
try {
|
|
81
|
+
const style = window.getComputedStyle(el as HTMLElement)
|
|
82
|
+
if (style.cursor === 'pointer') tokens.push('cursor=pointer')
|
|
83
|
+
} catch {
|
|
84
|
+
// 某些元素 getComputedStyle 可能抛异常,忽略
|
|
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')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 检测 CSS 激活/选中状态类名(用于未使用标准 ARIA 的 Tab/选项组件,如华为云镜像选择)
|
|
106
|
+
// 若元素携带常见激活类名,输出 [active] token,帮助 Agent 判断当前选中项
|
|
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
|
+
}
|
|
129
|
+
|
|
130
|
+
// 检测校验错误/警告状态(ARIA 标准 + 主流 UI 框架,可配置)
|
|
131
|
+
// 输出 [error] / [warning] token,让 AI 能区分校验错误与普通说明文字
|
|
132
|
+
// 使用 closest() 向上查找,确保嵌套在错误容器内的子元素也能获得 error 语义
|
|
133
|
+
const errorSelector = Array.isArray(errorSelectors)
|
|
134
|
+
? errorSelectors.join(', ')
|
|
135
|
+
: (errorSelectors || DEFAULT_ERROR_SELECTORS.join(', '))
|
|
136
|
+
const warningSelector = Array.isArray(warningSelectors)
|
|
137
|
+
? warningSelectors.join(', ')
|
|
138
|
+
: (warningSelectors || DEFAULT_WARNING_SELECTORS.join(', '))
|
|
139
|
+
let errorAncestor = null
|
|
140
|
+
if (errorSelector) {
|
|
141
|
+
try {
|
|
142
|
+
errorAncestor = el.closest(errorSelector)
|
|
143
|
+
} catch {
|
|
144
|
+
// ignore
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (errorAncestor) {
|
|
149
|
+
tokens.push('error')
|
|
150
|
+
} else {
|
|
151
|
+
let warningAncestor = null
|
|
152
|
+
if (warningSelector) {
|
|
153
|
+
try {
|
|
154
|
+
warningAncestor = el.closest(warningSelector)
|
|
155
|
+
} catch {
|
|
156
|
+
// ignore
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (warningAncestor) {
|
|
160
|
+
tokens.push('warning')
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 额外暴露的自定义属性白名单
|
|
165
|
+
if (exposedAttributes) {
|
|
166
|
+
for (const attr of exposedAttributes) {
|
|
167
|
+
const val = el.getAttribute(attr)
|
|
168
|
+
if (val !== null) {
|
|
169
|
+
tokens.push(`${attr}="${val}"`)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return tokens
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 判断元素是否应被跳过(不可见或在黑名单中) */
|
|
178
|
+
export function isHidden(el: Element): boolean {
|
|
179
|
+
if (el.getAttribute('aria-hidden') === 'true') return true
|
|
180
|
+
if ((el as HTMLElement).hidden) return true
|
|
181
|
+
try {
|
|
182
|
+
const style = window.getComputedStyle(el as HTMLElement)
|
|
183
|
+
if (style.display === 'none' || style.visibility === 'hidden') return true
|
|
184
|
+
// opacity:0 是常见的视觉隐藏方式,元素对用户不可见
|
|
185
|
+
if (style.opacity === '0') return true
|
|
186
|
+
// 宽或高为 0 且有 overflow 裁剪时,子内容实际不可见(如折叠的侧边栏 width:0 !important)
|
|
187
|
+
// 若 overflow 为 visible,子内容仍然溢出可见,不能过滤
|
|
188
|
+
const isClipX = style.overflowX !== 'visible'
|
|
189
|
+
const isClipY = style.overflowY !== 'visible'
|
|
190
|
+
if (parseFloat(style.width) === 0 && isClipX) return true
|
|
191
|
+
if (parseFloat(style.height) === 0 && isClipY) return true
|
|
192
|
+
} catch {
|
|
193
|
+
// 忽略跨域 iframe 等无法访问 style 的场景
|
|
194
|
+
}
|
|
195
|
+
return false
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 收集子孙节点的文本内容,用作无障碍名字的兜底。
|
|
201
|
+
* 当普通计算无法提取文本时,遍历后代并拼接可见文本。
|
|
202
|
+
*/
|
|
203
|
+
export function collectDescendantText(el: Element): string {
|
|
204
|
+
let text = ''
|
|
205
|
+
const walk = (node: Node) => {
|
|
206
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
207
|
+
text += (node.textContent ?? '') + ' '
|
|
208
|
+
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
|
209
|
+
const element = node as Element
|
|
210
|
+
if (isHidden(element)) return
|
|
211
|
+
|
|
212
|
+
// 如果遇到嵌套的列表项或其他交互/语义节点,停止向下遍历该子树,避免兜底文本重复吸收
|
|
213
|
+
if (element !== el) {
|
|
214
|
+
const tag = element.tagName.toLowerCase()
|
|
215
|
+
const role = inferRole(element)
|
|
216
|
+
const isInteractiveTag = ['button', 'a', 'input', 'select', 'textarea', 'li', 'option'].includes(tag)
|
|
217
|
+
const isInteractiveRole = ['button', 'link', 'checkbox', 'radio', 'textbox', 'listitem', 'option', 'combobox', 'listbox'].includes(role)
|
|
218
|
+
const isTrulyInteractive = isTabbable(element as HTMLElement)
|
|
219
|
+
let isCursorPointer = false
|
|
220
|
+
try {
|
|
221
|
+
isCursorPointer = window.getComputedStyle(element as HTMLElement).cursor === 'pointer'
|
|
222
|
+
} catch { /* ignore */ }
|
|
223
|
+
const isMeaningfullyInteractive = isTrulyInteractive && !(role === 'generic' && !isCursorPointer)
|
|
224
|
+
|
|
225
|
+
if (isMeaningfullyInteractive || isInteractiveTag || isInteractiveRole) {
|
|
226
|
+
return
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
for (const child of Array.from(element.childNodes)) {
|
|
231
|
+
walk(child)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
walk(el)
|
|
236
|
+
return text.trim().replace(/\s+/g, ' ')
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 获取元素在 composed tree(组合树)中的有效子元素
|
|
241
|
+
*
|
|
242
|
+
* Shadow DOM 场景下,简单拼接 el.children + el.shadowRoot.children 不符合浏览器
|
|
243
|
+
* 实际渲染和无障碍树使用的组合树语义:slotted 节点应出现在 <slot> 的位置而非
|
|
244
|
+
* host 下,<slot> 本身不应作为噪音节点出现,未被任何 slot 接收的 light children
|
|
245
|
+
* 在组合树中不可见。
|
|
246
|
+
*
|
|
247
|
+
* 因此:有 shadowRoot 时遍历 shadow tree,遇到 <slot> 用 assignedElements 替换;
|
|
248
|
+
* 无 shadowRoot 时直接遍历 light DOM children。
|
|
249
|
+
*/
|
|
250
|
+
export function getComposedChildren(el: Element): Element[] {
|
|
251
|
+
// <slot> 可嵌套在 shadow tree 的任意层级(如 <div><slot/></div>),递归时
|
|
252
|
+
// wrapper 节点无 shadowRoot 走 light 分支,因此两个分支都需解析 <slot>。
|
|
253
|
+
// light DOM 中不会出现 <slot>,检查无副作用。
|
|
254
|
+
const source = el.shadowRoot ? el.shadowRoot.children : el.children
|
|
255
|
+
const result: Element[] = []
|
|
256
|
+
for (const node of Array.from(source)) {
|
|
257
|
+
if (node instanceof HTMLSlotElement) {
|
|
258
|
+
const assigned = node.assignedElements({ flatten: true })
|
|
259
|
+
// 有分配节点时用 slotted 内容;否则用 slot 的 fallback content
|
|
260
|
+
result.push(...(assigned.length > 0 ? assigned : Array.from(node.children)))
|
|
261
|
+
} else {
|
|
262
|
+
result.push(node)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return result
|
|
266
|
+
}
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* a11y/vnode.ts
|
|
3
|
+
*
|
|
4
|
+
* 处理无障碍树节点 (VNode) 的构建、剪枝与序列化逻辑。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { computeAccessibleName } from 'dom-accessibility-api'
|
|
8
|
+
import { isFocusable, isTabbable } from 'tabbable'
|
|
9
|
+
import type { VNode, RefMap, A11yTreeOptions } from './types'
|
|
10
|
+
import { isHidden, inferRole, getStateTokens, collectDescendantText, getComposedChildren } from './utils'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 递归将 DOM 元素转换为 VNode 中间表示
|
|
14
|
+
* @param el 当前 DOM 元素
|
|
15
|
+
* @param refCounter 引用计数器(使用对象引用避免全局可变状态)
|
|
16
|
+
* @param refMap ref 索引 → 元素映射
|
|
17
|
+
* @param blacklistSet 用户自定义黑名单
|
|
18
|
+
* @param whitelistSet 用户自定义白名单
|
|
19
|
+
* @param exposedAttributes 需要暴露的自定义属性
|
|
20
|
+
* @param errorSelectors 错误选择器
|
|
21
|
+
* @param warningSelectors 警告选择器
|
|
22
|
+
*/
|
|
23
|
+
export function buildVNode(
|
|
24
|
+
el: Element,
|
|
25
|
+
refCounter: { value: number },
|
|
26
|
+
refMap: RefMap,
|
|
27
|
+
blacklistSet: Set<Element>,
|
|
28
|
+
whitelistSet: Set<Element>,
|
|
29
|
+
exposedAttributes?: string[],
|
|
30
|
+
errorSelectors?: string | string[],
|
|
31
|
+
warningSelectors?: string | string[],
|
|
32
|
+
/** 祖先节点是否已是可交互节点(已分配 ref)。为 true 时,纯 CSS 继承的 cursor=pointer 不再额外分配 ref */
|
|
33
|
+
ancestorIsInteractive = false,
|
|
34
|
+
): VNode | null {
|
|
35
|
+
if (isHidden(el) || blacklistSet.has(el)) return null
|
|
36
|
+
|
|
37
|
+
const role = inferRole(el)
|
|
38
|
+
const tokens = getStateTokens(el, exposedAttributes, errorSelectors, warningSelectors)
|
|
39
|
+
|
|
40
|
+
let name = computeAccessibleName(el as HTMLElement)
|
|
41
|
+
const isTrulyInteractive = isTabbable(el as HTMLElement)
|
|
42
|
+
const isVisuallyClickable = tokens.includes('cursor=pointer')
|
|
43
|
+
// 包含白名单属性的节点也视为白名单节点(确保不被剪枝并分配 ref 操作索引)
|
|
44
|
+
const isWhitelisted = whitelistSet.has(el) || (exposedAttributes?.some(attr => el.hasAttribute(attr)) ?? false)
|
|
45
|
+
// <label for="..."> 原生可点击:浏览器将点击转发到关联的表单控件(checkbox/radio 等)
|
|
46
|
+
// Angular/React 自定义组件常隐藏原生 input,仅暴露 label 文本和自定义 skin
|
|
47
|
+
const isLabelFor = el.tagName.toLowerCase() === 'label' && el.hasAttribute('for')
|
|
48
|
+
|
|
49
|
+
// 兜底方案:如果 AccName 计算结果为空,但该节点具有明显的交互性或属于结构性列表项,
|
|
50
|
+
// 我们从其子树收集纯文本作为其 fallback name,以防 AI 丢失可读上下文。
|
|
51
|
+
// 注意:不要对 <select> / combobox / listbox 等下拉组件进行文本兜底,以防它们吸收所有子选项文本导致极其嘈杂。
|
|
52
|
+
if (!name.trim()) {
|
|
53
|
+
const tag = el.tagName.toLowerCase()
|
|
54
|
+
const isDropdown = tag === 'select' || role === 'combobox' || role === 'listbox'
|
|
55
|
+
if (!isDropdown) {
|
|
56
|
+
const isInteractiveTag = ['button', 'a', 'input', 'textarea', 'li', 'label'].includes(tag)
|
|
57
|
+
if (isTrulyInteractive || isWhitelisted || isVisuallyClickable || isInteractiveTag || role === 'listitem' || role === 'option') {
|
|
58
|
+
name = collectDescendantText(el)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// 最终兜底:非交互元素(如错误提示 ti-error-msg、状态信息)的纯文本捕获。
|
|
64
|
+
// 仅当元素没有有价值的子元素时触发,避免与子节点文本重复:
|
|
65
|
+
// - 叶子 span(只有文本节点)→ 捕获文本作为 name
|
|
66
|
+
// - 容器元素(有 element 子节点)→ 不触发,由子节点各自捕获
|
|
67
|
+
if (!name.trim()) {
|
|
68
|
+
const hasElementChildren = getComposedChildren(el).length > 0
|
|
69
|
+
if (!hasElementChildren) {
|
|
70
|
+
const text = collectDescendantText(el)
|
|
71
|
+
if (text) {
|
|
72
|
+
name = text
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// generic 无 name 时,即使有 cursor=pointer 也不分配 ref:
|
|
78
|
+
// cursor 通常是 CSS 继承传播的,这类 div 本身无法被有意义地操作。
|
|
79
|
+
// 此外,当祖先节点已是可交互节点时,子节点仅凭 isVisuallyClickable(CSS 继承)
|
|
80
|
+
// 不再额外分配 ref,避免父节点 cursor:pointer 传染给所有子孙导致高亮泛滥。
|
|
81
|
+
//
|
|
82
|
+
// 例外:<a>/<button>/<input> 等语义性交互标签,无论祖先是否已有 ref,
|
|
83
|
+
// 始终强制分配 ref,因为它们在 HTML 语义上就是独立的操作单元。
|
|
84
|
+
const tagName = el.tagName.toLowerCase()
|
|
85
|
+
const isSemanticInteractiveTag =
|
|
86
|
+
(tagName === 'a' && el.hasAttribute('href')) ||
|
|
87
|
+
(['button', 'input', 'select', 'textarea'].includes(tagName) && !el.hasAttribute('disabled'))
|
|
88
|
+
|
|
89
|
+
// 虽然有些元素有 tabindex="0" (isTrulyInteractive),但如果它是 generic 且没有 cursor:pointer,
|
|
90
|
+
// 往往是开发者加的结构化 focus 容器(如 tp-card),而非真正的可交互按钮,我们在此过滤掉它们。
|
|
91
|
+
const isMeaningfullyInteractive = isTrulyInteractive && !(role === 'generic' && !isVisuallyClickable)
|
|
92
|
+
|
|
93
|
+
const interactive =
|
|
94
|
+
isMeaningfullyInteractive ||
|
|
95
|
+
isWhitelisted ||
|
|
96
|
+
isLabelFor ||
|
|
97
|
+
isSemanticInteractiveTag ||
|
|
98
|
+
(!ancestorIsInteractive && isVisuallyClickable && (role !== 'generic' || name !== ''))
|
|
99
|
+
|
|
100
|
+
let ref: number | undefined
|
|
101
|
+
if (interactive) {
|
|
102
|
+
ref = refCounter.value
|
|
103
|
+
refMap.set(ref, el as HTMLElement)
|
|
104
|
+
refCounter.value++
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const children: VNode[] = []
|
|
108
|
+
for (const child of getComposedChildren(el)) {
|
|
109
|
+
const childVNode = buildVNode(
|
|
110
|
+
child,
|
|
111
|
+
refCounter,
|
|
112
|
+
refMap,
|
|
113
|
+
blacklistSet,
|
|
114
|
+
whitelistSet,
|
|
115
|
+
exposedAttributes,
|
|
116
|
+
errorSelectors,
|
|
117
|
+
warningSelectors,
|
|
118
|
+
// 将当前节点的交互性向下传递,子节点据此决定是否抑制 cursor=pointer
|
|
119
|
+
interactive || ancestorIsInteractive,
|
|
120
|
+
)
|
|
121
|
+
if (childVNode) children.push(childVNode)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { role, name, tokens, ref, el: el as HTMLElement, children }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 判断 VNode 子树是否包含任何有价值的节点(有 ref 或有 accessible name)
|
|
129
|
+
* 用于过滤空容器子树,避免输出无内容的嵌套层级
|
|
130
|
+
*/
|
|
131
|
+
export function hasValue(vnode: VNode): boolean {
|
|
132
|
+
if (vnode.ref !== undefined || vnode.name.trim() !== '') return true
|
|
133
|
+
return vnode.children.some(hasValue)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* 检查节点子树中是否存在任何有 ref 的可交互子节点。
|
|
138
|
+
* 用于判断父节点是否可以安全省略其子节点输出。
|
|
139
|
+
*/
|
|
140
|
+
function hasInteractiveDescendant(vnode: VNode): boolean {
|
|
141
|
+
return vnode.children.some(c => c.ref !== undefined || hasInteractiveDescendant(c))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 找到子树中恰好唯一一个有 ref 的节点,若有多个则返回 null。
|
|
146
|
+
* 用于判断是否可将该子节点与父节点合并输出。
|
|
147
|
+
*/
|
|
148
|
+
function findSingleRefDescendant(vnode: VNode): VNode | null {
|
|
149
|
+
const refs: VNode[] = []
|
|
150
|
+
const collect = (v: VNode) => {
|
|
151
|
+
if (v.ref !== undefined) refs.push(v)
|
|
152
|
+
if (refs.length > 1) return // 超过一个就提前退出
|
|
153
|
+
v.children.forEach(collect)
|
|
154
|
+
}
|
|
155
|
+
vnode.children.forEach(collect)
|
|
156
|
+
return refs.length === 1 ? refs[0] : null
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 判断节点是否需要透明穿透(跳过本节点但保留子节点)
|
|
161
|
+
*
|
|
162
|
+
* 统一规则:无 ref(非交互)且无 accessible name → 穿透
|
|
163
|
+
* 这样可以同时:
|
|
164
|
+
* - 去掉 generic/list/listitem 等纯布局噪音(无 name 时穿透)
|
|
165
|
+
* - 保留有 name 的 listitem(有 name 时保留,兼顾内容理解场景)
|
|
166
|
+
*/
|
|
167
|
+
export function shouldPassThrough(vnode: VNode, opts: Required<A11yTreeOptions>): boolean {
|
|
168
|
+
if (!opts.pruneUnnamed) return false
|
|
169
|
+
// preserveRoles 中的角色强制保留
|
|
170
|
+
if (opts.preserveRoles.includes(vnode.role)) return false
|
|
171
|
+
// 有 ref(交互节点)→ 永远保留
|
|
172
|
+
if (vnode.ref !== undefined) return false
|
|
173
|
+
// 有 accessible name → 保留(兼顾内容理解场景)
|
|
174
|
+
if (vnode.name.trim() !== '') return false
|
|
175
|
+
// 无 ref 且无 name → 透明穿透
|
|
176
|
+
return true
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 将 VNode 序列化为 YAML 行数组
|
|
181
|
+
* 穿透节点时,子节点在当前 depth 平铺输出(不增加缩进层级)
|
|
182
|
+
*/
|
|
183
|
+
export function serializeVNode(
|
|
184
|
+
vnode: VNode,
|
|
185
|
+
depth: number,
|
|
186
|
+
opts: Required<A11yTreeOptions>,
|
|
187
|
+
): string[] {
|
|
188
|
+
if (shouldPassThrough(vnode, opts)) {
|
|
189
|
+
// 透明穿透:跳过本节点,子节点保持当前 depth(层级不增加)
|
|
190
|
+
// 同时过滤掉整棵子树都无价値的空容器,避免输出无意义的嵌套
|
|
191
|
+
return vnode.children
|
|
192
|
+
.filter(c => hasValue(c))
|
|
193
|
+
.flatMap(c => serializeVNode(c, depth, opts))
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const indent = ' '.repeat(depth)
|
|
197
|
+
const refStr = vnode.ref !== undefined ? ` #${vnode.ref}` : ''
|
|
198
|
+
const safeTokens = vnode.tokens.map(t => t.replace(/[\r\n]+/g, ' ').replace(/"/g, '\\"'))
|
|
199
|
+
const tokenStr = safeTokens.length > 0 ? ` [${safeTokens.join(' ')}]` : ''
|
|
200
|
+
|
|
201
|
+
if (vnode.ref !== undefined) {
|
|
202
|
+
if (!hasInteractiveDescendant(vnode)) {
|
|
203
|
+
// 子树无任何 ref 节点 → 直接省略子节点
|
|
204
|
+
// 父节点的 name 已由 computeAccessibleName 汇总了子树文本,信息不会丢失
|
|
205
|
+
const safeName = vnode.name ? vnode.name.replace(/[\r\n]+/g, ' ').replace(/"/g, '\\"') : ''
|
|
206
|
+
const nameStr = safeName ? ` "${safeName}"` : ''
|
|
207
|
+
return [`${indent}- ${vnode.role}${refStr}${tokenStr}${nameStr}`]
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 子树中唯一一个 ref 节点且是无子 ref 的 generic 时,将其 name 提升到父节点,省略子节点输出。
|
|
211
|
+
// 场景:`listitem #22 [active]` 内部只有 `generic #23 "总览"`,
|
|
212
|
+
// 点击意义相同时,就展示为一行 `- listitem #22 [active] "总览"`。
|
|
213
|
+
const singleChild = findSingleRefDescendant(vnode)
|
|
214
|
+
if (singleChild && singleChild.role === 'generic' && !hasInteractiveDescendant(singleChild)) {
|
|
215
|
+
const mergedName = (vnode.name.trim() || singleChild.name.trim())
|
|
216
|
+
.replace(/[\r\n]+/g, ' ')
|
|
217
|
+
.replace(/"/g, '\\"')
|
|
218
|
+
const nameStr = mergedName ? ` "${mergedName}"` : ''
|
|
219
|
+
return [`${indent}- ${vnode.role}${refStr}${tokenStr}${nameStr}`]
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const safeName = vnode.name ? vnode.name.replace(/[\r\n]+/g, ' ').replace(/"/g, '\\"') : ''
|
|
224
|
+
const nameStr = safeName ? ` "${safeName}"` : ''
|
|
225
|
+
const line = `${indent}- ${vnode.role}${refStr}${tokenStr}${nameStr}`
|
|
226
|
+
|
|
227
|
+
const childLines = vnode.children.flatMap(c => serializeVNode(c, depth + 1, opts))
|
|
228
|
+
return [line, ...childLines]
|
|
229
|
+
}
|