@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.
Files changed (64) hide show
  1. package/dist/{SimulatorMask-BHVXyogh-iNUb4dyF.js → SimulatorMask-BHVXyogh-CARX3Rff.js} +1 -1
  2. package/dist/core.js +1 -1
  3. package/dist/{index-CXK8CcKq.js → index-R_HIbfUX.js} +1698 -1170
  4. package/dist/index.d.ts +11 -0
  5. package/dist/index.js +69 -50
  6. package/dist/{initialize-builtin-WebMCP-rALfqS-o.js → initialize-builtin-WebMCP-JaoKwVlm.js} +355 -346
  7. package/dist/page-tools/a11y/build.d.ts +3 -4
  8. package/dist/page-tools/a11y/config.d.ts +96 -0
  9. package/dist/page-tools/a11y/constants.d.ts +2 -0
  10. package/dist/page-tools/a11y/search.d.ts +1 -1
  11. package/dist/page-tools/a11y/types.d.ts +13 -22
  12. package/dist/page-tools/a11y/utils.d.ts +32 -11
  13. package/dist/page-tools/a11y/vnode.d.ts +14 -11
  14. package/dist/page-tools/a11y-tree.d.ts +1 -0
  15. package/dist/page-tools/configs/console-cloud.d.ts +6 -0
  16. package/dist/page-tools/constants.d.ts +5 -17
  17. package/dist/page-tools/context.d.ts +12 -0
  18. package/dist/page-tools/handlers/searchTree.d.ts +2 -1
  19. package/dist/page-tools/page-agent-highlight/index.d.ts +12 -0
  20. package/dist/page-tools/page-agent-mask/SimulatorMask.d.ts +2 -0
  21. package/dist/page-tools/page-agent-tool-event.d.ts +26 -0
  22. package/dist/page-tools/schema.d.ts +1 -0
  23. package/dist/page-tools/tool-config.d.ts +50 -0
  24. package/dist/runtime.d.ts +4 -1
  25. package/dist/runtime.js +37 -49
  26. package/dist/vitest.config.d.ts +2 -0
  27. package/index.ts +35 -0
  28. package/package.json +21 -16
  29. package/page-tools/a11y/build.ts +38 -34
  30. package/page-tools/a11y/config.ts +465 -0
  31. package/page-tools/a11y/constants.ts +21 -0
  32. package/page-tools/a11y/search.ts +1 -3
  33. package/page-tools/a11y/types.ts +14 -17
  34. package/page-tools/a11y/utils.ts +129 -156
  35. package/page-tools/a11y/vnode.ts +277 -67
  36. package/page-tools/a11y-tree.ts +1 -0
  37. package/page-tools/bridge.ts +22 -2
  38. package/page-tools/configs/console-cloud.ts +172 -0
  39. package/page-tools/constants.ts +5 -65
  40. package/page-tools/context.ts +38 -1
  41. package/page-tools/handlers/click.ts +6 -2
  42. package/page-tools/handlers/fill.ts +10 -5
  43. package/page-tools/handlers/searchTree.ts +7 -10
  44. package/page-tools/handlers/select.ts +10 -5
  45. package/page-tools/page-agent-highlight/index.ts +66 -19
  46. package/page-tools/page-agent-mask/SimulatorMask.ts +42 -22
  47. package/page-tools/page-agent-mask/hauwei.svg +25 -0
  48. package/page-tools/page-agent-prompt.md +1 -1
  49. package/page-tools/page-agent-tool-event.ts +113 -0
  50. package/page-tools/page-agent-tool.ts +85 -53
  51. package/page-tools/schema.ts +1 -0
  52. package/page-tools/tool-config.ts +100 -0
  53. package/page-tools/utils/dom.ts +6 -3
  54. package/runtime.ts +37 -6
  55. package/test/page-tools/a11y/build.test.ts +638 -0
  56. package/test/page-tools/a11y/config.test.ts +370 -0
  57. package/test/page-tools/configs/console-cloud.test.ts +168 -0
  58. package/test/page-tools/page-agent-highlight.test.ts +110 -0
  59. package/test/page-tools/page-agent-tool-dispatch.test.ts +208 -0
  60. package/test/page-tools/page-agent-tool.test.ts +102 -0
  61. package/test/page-tools/tool-config.test.ts +112 -0
  62. package/test/page-tools/utils/dom.test.ts +122 -0
  63. package/vite.config.runtime.ts +1 -1
  64. 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, blacklist, whitelist, treeOptions)
40
+ const { lines, refMap, yaml } = buildA11yTree(root, treeOptions)
43
41
 
44
42
  const needle = caseInsensitive ? query.toLowerCase() : query
45
43
  const totalLines = lines.length
@@ -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
- export interface A11yTreeOptions {
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
- * 允许在无障碍树节点中作为 token 额外输出的 DOM 属性白名单
40
- * 包含这些属性的节点会被自动视为需要暴露/交互的节点(分配 ref 并保留),
41
- * 且属性及其值会显示在节点的 token 列表中,如 [cf-uba="cloudShell"]
42
- */
43
- exposedAttributes?: string[]
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
  /** 构建无障碍树的返回结果 */
@@ -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 { isFocusable, isTabbable } from 'tabbable'
8
- import { TAG_ROLE_MAP, INPUT_TYPE_ROLE, DEFAULT_ERROR_SELECTORS, DEFAULT_WARNING_SELECTORS } from './constants'
8
+ import { isTabbable } from 'tabbable'
9
+ import { resolveA11yRole, type ResolvedA11yConfig } from './config'
10
+ import { deepQuerySelectorAll } from '../utils/dom'
9
11
 
10
12
  /**
11
- * 获取元素的 ARIA 角色
12
- * 优先级:显式 role 属性 > 标签隐式角色 > 'generic'
13
+ * 非内容元素:不应进入无障碍树,其内部文本也不得被 AccName / textContent 兜底吸收。
14
+ * 云控制台常见在 body 内联 `<style>@font-face { src: url(data:font/...;base64,...) }</style>`,
15
+ * 若不剔除,整段 base64 会变成 generic 的 name,严重浪费 LLM token。
13
16
  */
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'
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
- * 收集节点的 ARIA 状态 token
29
- * 格式:[checked] [selected] [disabled] [hasPopup] [cursor=pointer] [value="..."]
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 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 表示"视觉上可点击"
47
+ export function hasOwnPointerCursor(el: Element): boolean {
80
48
  try {
81
- const style = window.getComputedStyle(el as HTMLElement)
82
- if (style.cursor === 'pointer') tokens.push('cursor=pointer')
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
- // 某些元素 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')
54
+ // 忽略跨域 iframe 等无法访问 style 的场景
55
+ return false
103
56
  }
57
+ }
104
58
 
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
- }
59
+ /** 交互态伪类:cursor 常只在这些状态下声明为 pointer(如卡片 hover 时才显示手势) */
60
+ const INTERACTION_PSEUDO_RE = /:(hover|focus|focus-visible|focus-within|active)\b/gi
129
61
 
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
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
- if (errorAncestor) {
149
- tokens.push('error')
150
- } else {
151
- let warningAncestor = null
152
- if (warningSelector) {
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
- warningAncestor = el.closest(warningSelector)
103
+ rules = sheet.cssRules
155
104
  } catch {
156
- // ignore
105
+ // 跨域样式表无法访问 cssRules,跳过
106
+ continue
157
107
  }
108
+ if (rules) collectInteractivePointerSelectors(rules, selectorSet)
158
109
  }
159
- if (warningAncestor) {
160
- tokens.push('warning')
161
- }
110
+ } catch {
111
+ return set
162
112
  }
113
+ if (selectorSet.size === 0) return set
163
114
 
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}"`)
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
- if (isHidden(element)) return
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 = inferRole(element)
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
- 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
-
195
+ const isOwnPointer = hasOwnPointerCursor(element)
196
+ const isMeaningfullyInteractive = isTrulyInteractive && !(role === 'generic' && !isOwnPointer)
197
+
225
198
  if (isMeaningfullyInteractive || isInteractiveTag || isInteractiveRole) {
226
199
  return
227
200
  }