@tanstack/devtools-a11y 0.0.1 → 0.1.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.
Files changed (110) hide show
  1. package/LICENSE +21 -0
  2. package/dist/esm/core/components/IssueCard.d.ts +10 -0
  3. package/dist/esm/core/components/IssueCard.js +83 -0
  4. package/dist/esm/core/components/IssueCard.js.map +1 -0
  5. package/dist/esm/core/components/IssueList.d.ts +6 -0
  6. package/dist/esm/core/components/IssueList.js +134 -0
  7. package/dist/esm/core/components/IssueList.js.map +1 -0
  8. package/dist/esm/core/components/Settings.d.ts +6 -0
  9. package/dist/esm/core/components/Settings.js +251 -0
  10. package/dist/esm/core/components/Settings.js.map +1 -0
  11. package/dist/esm/core/components/Shell.d.ts +2 -0
  12. package/dist/esm/core/components/Shell.js +214 -0
  13. package/dist/esm/core/components/Shell.js.map +1 -0
  14. package/dist/esm/core/components/index.d.ts +2 -0
  15. package/dist/esm/core/components/index.js +14 -0
  16. package/dist/esm/core/components/index.js.map +1 -0
  17. package/dist/esm/core/contexts/allyContext.d.ts +17 -0
  18. package/dist/esm/core/contexts/allyContext.js +66 -0
  19. package/dist/esm/core/contexts/allyContext.js.map +1 -0
  20. package/dist/esm/core/core.d.ts +19 -0
  21. package/dist/esm/core/core.js +8 -0
  22. package/dist/esm/core/core.js.map +1 -0
  23. package/dist/esm/core/index.d.ts +9 -0
  24. package/dist/esm/core/index.js +9 -0
  25. package/dist/esm/core/index.js.map +1 -0
  26. package/dist/esm/core/production.d.ts +2 -0
  27. package/dist/esm/core/production.js +4 -0
  28. package/dist/esm/core/styles/styles.d.ts +85 -0
  29. package/dist/esm/core/styles/styles.js +547 -0
  30. package/dist/esm/core/styles/styles.js.map +1 -0
  31. package/dist/esm/core/types/types.d.ts +141 -0
  32. package/dist/esm/core/utils/ally-audit.utils.d.ts +19 -0
  33. package/dist/esm/core/utils/ally-audit.utils.js +226 -0
  34. package/dist/esm/core/utils/ally-audit.utils.js.map +1 -0
  35. package/dist/esm/core/utils/config.utils.d.ts +17 -0
  36. package/dist/esm/core/utils/config.utils.js +63 -0
  37. package/dist/esm/core/utils/config.utils.js.map +1 -0
  38. package/dist/esm/core/utils/custom-audit.utils.d.ts +13 -0
  39. package/dist/esm/core/utils/custom-audit.utils.js +426 -0
  40. package/dist/esm/core/utils/custom-audit.utils.js.map +1 -0
  41. package/dist/esm/core/utils/export-audit.uitls.d.ts +17 -0
  42. package/dist/esm/core/utils/export-audit.uitls.js +83 -0
  43. package/dist/esm/core/utils/export-audit.uitls.js.map +1 -0
  44. package/dist/esm/core/utils/ui.utils.d.ts +24 -0
  45. package/dist/esm/core/utils/ui.utils.js +330 -0
  46. package/dist/esm/core/utils/ui.utils.js.map +1 -0
  47. package/dist/esm/react/A11yDevtools.d.ts +5 -0
  48. package/dist/esm/react/A11yDevtools.js +8 -0
  49. package/dist/esm/react/A11yDevtools.js.map +1 -0
  50. package/dist/esm/react/index.d.ts +8 -0
  51. package/dist/esm/react/index.js +11 -0
  52. package/dist/esm/react/index.js.map +1 -0
  53. package/dist/esm/react/plugin.d.ts +12 -0
  54. package/dist/esm/react/plugin.js +11 -0
  55. package/dist/esm/react/plugin.js.map +1 -0
  56. package/dist/esm/react/production/A11yDevtools.d.ts +5 -0
  57. package/dist/esm/react/production/A11yDevtools.js +8 -0
  58. package/dist/esm/react/production/A11yDevtools.js.map +1 -0
  59. package/dist/esm/react/production/plugin.d.ts +7 -0
  60. package/dist/esm/react/production/plugin.js +11 -0
  61. package/dist/esm/react/production/plugin.js.map +1 -0
  62. package/dist/esm/react/production.d.ts +3 -0
  63. package/dist/esm/react/production.js +5 -0
  64. package/dist/esm/solid/A11yDevtools.d.ts +5 -0
  65. package/dist/esm/solid/A11yDevtools.js +8 -0
  66. package/dist/esm/solid/A11yDevtools.js.map +1 -0
  67. package/dist/esm/solid/index.d.ts +8 -0
  68. package/dist/esm/solid/index.js +9 -0
  69. package/dist/esm/solid/index.js.map +1 -0
  70. package/dist/esm/solid/plugin.d.ts +12 -0
  71. package/dist/esm/solid/plugin.js +11 -0
  72. package/dist/esm/solid/plugin.js.map +1 -0
  73. package/dist/esm/solid/production/A11yDevtools.d.ts +5 -0
  74. package/dist/esm/solid/production/A11yDevtools.js +8 -0
  75. package/dist/esm/solid/production/A11yDevtools.js.map +1 -0
  76. package/dist/esm/solid/production/plugin.d.ts +7 -0
  77. package/dist/esm/solid/production/plugin.js +11 -0
  78. package/dist/esm/solid/production/plugin.js.map +1 -0
  79. package/dist/esm/solid/production.d.ts +3 -0
  80. package/dist/esm/solid/production.js +3 -0
  81. package/package.json +110 -7
  82. package/src/core/components/IssueCard.tsx +75 -0
  83. package/src/core/components/IssueList.tsx +155 -0
  84. package/src/core/components/Settings.tsx +221 -0
  85. package/src/core/components/Shell.tsx +154 -0
  86. package/src/core/components/index.tsx +12 -0
  87. package/src/core/contexts/allyContext.tsx +118 -0
  88. package/src/core/core.tsx +11 -0
  89. package/src/core/index.ts +10 -0
  90. package/src/core/production.ts +5 -0
  91. package/src/core/styles/styles.ts +556 -0
  92. package/src/core/types/types.ts +177 -0
  93. package/src/core/utils/ally-audit.utils.ts +345 -0
  94. package/src/core/utils/config.utils.ts +68 -0
  95. package/src/core/utils/custom-audit.utils.ts +643 -0
  96. package/src/core/utils/export-audit.uitls.ts +180 -0
  97. package/src/core/utils/ui.utils.ts +483 -0
  98. package/src/react/A11yDevtools.ts +12 -0
  99. package/src/react/index.ts +16 -0
  100. package/src/react/plugin.ts +9 -0
  101. package/src/react/production/A11yDevtools.ts +11 -0
  102. package/src/react/production/plugin.ts +9 -0
  103. package/src/react/production.ts +7 -0
  104. package/src/solid/A11yDevtools.ts +11 -0
  105. package/src/solid/index.ts +14 -0
  106. package/src/solid/plugin.ts +9 -0
  107. package/src/solid/production/A11yDevtools.ts +10 -0
  108. package/src/solid/production/plugin.ts +9 -0
  109. package/src/solid/production.ts +5 -0
  110. package/README.md +0 -45
@@ -0,0 +1,180 @@
1
+ import type { A11yAuditResult, ExportOptions } from '../types/types'
2
+
3
+ /**
4
+ * Export audit results to JSON format
5
+ */
6
+ export function exportToJson(
7
+ result: A11yAuditResult,
8
+ _options: Partial<ExportOptions> = {},
9
+ ): string {
10
+ const exportData = {
11
+ meta: {
12
+ exportedAt: new Date().toISOString(),
13
+ url: result.url,
14
+ auditTimestamp: result.timestamp,
15
+ duration: result.duration,
16
+ context: result.context,
17
+ },
18
+ summary: result.summary,
19
+ issues: result.issues.map((issue) => ({
20
+ id: issue.id,
21
+ ruleId: issue.ruleId,
22
+ impact: issue.impact,
23
+ message: issue.message,
24
+ help: issue.help,
25
+ helpUrl: issue.helpUrl,
26
+ wcagTags: issue.wcagTags,
27
+ nodes: issue.nodes.map((node) => ({
28
+ selector: node.selector,
29
+ html: node.html,
30
+ failureSummary: node.failureSummary,
31
+ })),
32
+ })),
33
+ }
34
+
35
+ return JSON.stringify(exportData, null, 2)
36
+ }
37
+
38
+ /**
39
+ * Export audit results to CSV format
40
+ */
41
+ export function exportToCsv(
42
+ result: A11yAuditResult,
43
+ _options: Partial<ExportOptions> = {},
44
+ ): string {
45
+ const headers = [
46
+ 'Rule ID',
47
+ 'Impact',
48
+ 'Message',
49
+ 'Help URL',
50
+ 'WCAG Tags',
51
+ 'Selector',
52
+ 'HTML',
53
+ ]
54
+
55
+ const rows: Array<Array<string>> = []
56
+
57
+ for (const issue of result.issues) {
58
+ for (const node of issue.nodes) {
59
+ rows.push([
60
+ issue.ruleId,
61
+ issue.impact,
62
+ issue.message.replace(/"/g, '""'),
63
+ issue.helpUrl,
64
+ issue.wcagTags.join('; '),
65
+ node.selector,
66
+ node.html.replace(/"/g, '""'),
67
+ ])
68
+ }
69
+ }
70
+
71
+ return [
72
+ headers.map((h) => `"${h}"`).join(','),
73
+ ...rows.map((row) => row.map((cell) => `"${cell}"`).join(',')),
74
+ ].join('\n')
75
+ }
76
+
77
+ /**
78
+ * Download a file with the given content
79
+ */
80
+ function downloadFile(
81
+ content: string,
82
+ filename: string,
83
+ mimeType: string,
84
+ ): void {
85
+ const blob = new Blob([content], { type: mimeType })
86
+ const url = URL.createObjectURL(blob)
87
+ const link = document.createElement('a')
88
+ link.href = url
89
+ link.download = filename
90
+ document.body.appendChild(link)
91
+ link.click()
92
+ document.body.removeChild(link)
93
+ URL.revokeObjectURL(url)
94
+ }
95
+
96
+ /**
97
+ * Export audit results and trigger download
98
+ */
99
+ export function exportAuditResults(
100
+ result: A11yAuditResult,
101
+ options: ExportOptions,
102
+ ): void {
103
+ const { format, filename } = options
104
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
105
+ const defaultFilename = `a11y-audit-${timestamp}`
106
+
107
+ if (format === 'json') {
108
+ const content = exportToJson(result, options)
109
+ downloadFile(
110
+ content,
111
+ `${filename || defaultFilename}.json`,
112
+ 'application/json',
113
+ )
114
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
115
+ } else if (format === 'csv') {
116
+ const content = exportToCsv(result, options)
117
+ downloadFile(content, `${filename || defaultFilename}.csv`, 'text/csv')
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Generate a summary report as a formatted string
123
+ */
124
+ export function generateSummaryReport(result: A11yAuditResult): string {
125
+ const { summary } = result
126
+
127
+ const lines = [
128
+ '='.repeat(50),
129
+ 'ACCESSIBILITY AUDIT REPORT',
130
+ '='.repeat(50),
131
+ '',
132
+ `URL: ${result.url}`,
133
+ `Date: ${new Date(result.timestamp).toLocaleString()}`,
134
+ `Duration: ${result.duration.toFixed(2)}ms`,
135
+ '',
136
+ '-'.repeat(50),
137
+ 'SUMMARY',
138
+ '-'.repeat(50),
139
+ '',
140
+ `Total Issues: ${summary.total}`,
141
+ ` - Critical: ${summary.critical}`,
142
+ ` - Serious: ${summary.serious}`,
143
+ ` - Moderate: ${summary.moderate}`,
144
+ ` - Minor: ${summary.minor}`,
145
+ '',
146
+ `Passing Rules: ${summary.passes}`,
147
+ `Incomplete Checks: ${summary.incomplete}`,
148
+ '',
149
+ ]
150
+
151
+ if (result.issues.length > 0) {
152
+ lines.push('-'.repeat(50))
153
+ lines.push('ISSUES')
154
+ lines.push('-'.repeat(50))
155
+ lines.push('')
156
+
157
+ const issuesByImpact = {
158
+ critical: result.issues.filter((i) => i.impact === 'critical'),
159
+ serious: result.issues.filter((i) => i.impact === 'serious'),
160
+ moderate: result.issues.filter((i) => i.impact === 'moderate'),
161
+ minor: result.issues.filter((i) => i.impact === 'minor'),
162
+ }
163
+
164
+ for (const [impact, issues] of Object.entries(issuesByImpact)) {
165
+ if (issues.length > 0) {
166
+ lines.push(`[${impact.toUpperCase()}]`)
167
+ for (const issue of issues) {
168
+ lines.push(` - ${issue.ruleId}: ${issue.message}`)
169
+ lines.push(` Selector: ${issue.nodes[0]?.selector}`)
170
+ lines.push(` Learn more: ${issue.helpUrl}`)
171
+ lines.push('')
172
+ }
173
+ }
174
+ }
175
+ }
176
+
177
+ lines.push('='.repeat(50))
178
+
179
+ return lines.join('\n')
180
+ }
@@ -0,0 +1,483 @@
1
+ // types
2
+ import type {
3
+ A11yIssue,
4
+ RuleSetPreset,
5
+ SeverityThreshold,
6
+ } from '../types/types'
7
+
8
+ export function scrollToElement(selector: string): boolean {
9
+ try {
10
+ const element = document.querySelector(selector)
11
+ if (element) {
12
+ element.scrollIntoView({
13
+ behavior: 'smooth',
14
+ block: 'start',
15
+ inline: 'nearest',
16
+ })
17
+ return true
18
+ }
19
+ } catch (error) {
20
+ console.warn('[A11y Panel] Could not scroll to element:', selector, error)
21
+ }
22
+ return false
23
+ }
24
+
25
+ const HIGHLIGHT_CLASS = 'tsd-a11y-highlight'
26
+ const HIGHLIGHT_STYLE_ID = 'tsd-a11y-highlight-styles'
27
+ const TOOLTIP_CLASS = 'tsd-a11y-tooltip'
28
+
29
+ // Track active tooltips and their target elements for scroll updates
30
+ const activeTooltips = new Map<HTMLElement, Element>()
31
+ let scrollHandler: (() => void) | null = null
32
+
33
+ // Tooltip height (padding + font size + some buffer)
34
+ const TOOLTIP_HEIGHT = 28
35
+
36
+ /**
37
+ * Severity levels mapped to numeric values for comparison (higher = more severe)
38
+ */
39
+ export const SEVERITY_ORDER: Record<SeverityThreshold, number> = {
40
+ critical: 4,
41
+ serious: 3,
42
+ moderate: 2,
43
+ minor: 1,
44
+ }
45
+
46
+ /**
47
+ * Selectors for devtools elements that should never be highlighted
48
+ */
49
+ const DEVTOOLS_SELECTORS = [
50
+ '[data-testid="tanstack_devtools"]',
51
+ '[data-devtools]',
52
+ '[data-devtools-panel]',
53
+ '[data-a11y-overlay]',
54
+ ]
55
+
56
+ /**
57
+ * Check if an element is inside the devtools panel
58
+ */
59
+ function isInsideDevtools(element: Element): boolean {
60
+ for (const selector of DEVTOOLS_SELECTORS) {
61
+ if (element.closest(selector)) {
62
+ return true
63
+ }
64
+ }
65
+ return false
66
+ }
67
+
68
+ export const SEVERITY_LABELS: Record<SeverityThreshold, string> = {
69
+ critical: 'Critical',
70
+ serious: 'Serious',
71
+ moderate: 'Moderate',
72
+ minor: 'Minor',
73
+ }
74
+
75
+ export const RULE_SET_LABELS: Record<RuleSetPreset, string> = {
76
+ wcag2a: 'WCAG 2.0 A',
77
+ wcag2aa: 'WCAG 2.0 AA',
78
+ wcag21aa: 'WCAG 2.1 AA',
79
+ wcag22aa: 'WCAG 2.2 AA',
80
+ section508: 'Section 508',
81
+ 'best-practice': 'Best Practice',
82
+ all: 'All Rules',
83
+ }
84
+
85
+ /**
86
+ * Color scheme for different severity levels
87
+ */
88
+ const SEVERITY_COLORS: Record<
89
+ SeverityThreshold,
90
+ { border: string; bg: string; text: string }
91
+ > = {
92
+ critical: {
93
+ border: '#dc2626',
94
+ bg: 'rgba(220, 38, 38, 0.15)',
95
+ text: '#dc2626',
96
+ },
97
+ serious: {
98
+ border: '#ea580c',
99
+ bg: 'rgba(234, 88, 12, 0.15)',
100
+ text: '#ea580c',
101
+ },
102
+ moderate: {
103
+ border: '#ca8a04',
104
+ bg: 'rgba(202, 138, 4, 0.15)',
105
+ text: '#ca8a04',
106
+ },
107
+ minor: { border: '#2563eb', bg: 'rgba(37, 99, 235, 0.15)', text: '#2563eb' },
108
+ }
109
+
110
+ /**
111
+ * Inject overlay styles into the document
112
+ */
113
+ function injectStyles(): void {
114
+ if (document.getElementById(HIGHLIGHT_STYLE_ID)) {
115
+ return
116
+ }
117
+
118
+ const style = document.createElement('style')
119
+ style.id = HIGHLIGHT_STYLE_ID
120
+ // Highlights use outline which doesn't affect layout
121
+ // Tooltips use fixed positioning to avoid layout shifts
122
+ style.textContent = `
123
+ .${HIGHLIGHT_CLASS}--critical {
124
+ outline: 3px solid ${SEVERITY_COLORS.critical.border} !important;
125
+ outline-offset: 2px !important;
126
+ background-color: ${SEVERITY_COLORS.critical.bg} !important;
127
+ }
128
+
129
+ .${HIGHLIGHT_CLASS}--serious {
130
+ outline: 3px solid ${SEVERITY_COLORS.serious.border} !important;
131
+ outline-offset: 2px !important;
132
+ background-color: ${SEVERITY_COLORS.serious.bg} !important;
133
+ }
134
+
135
+ .${HIGHLIGHT_CLASS}--moderate {
136
+ outline: 2px solid ${SEVERITY_COLORS.moderate.border} !important;
137
+ outline-offset: 2px !important;
138
+ background-color: ${SEVERITY_COLORS.moderate.bg} !important;
139
+ }
140
+
141
+ .${HIGHLIGHT_CLASS}--minor {
142
+ outline: 2px dashed ${SEVERITY_COLORS.minor.border} !important;
143
+ outline-offset: 2px !important;
144
+ background-color: ${SEVERITY_COLORS.minor.bg} !important;
145
+ }
146
+
147
+ .${TOOLTIP_CLASS} {
148
+ position: fixed;
149
+ padding: 4px 8px;
150
+ border-radius: 4px;
151
+ font-size: 11px;
152
+ font-weight: 600;
153
+ font-family: system-ui, -apple-system, sans-serif;
154
+ white-space: nowrap;
155
+ z-index: 99990;
156
+ pointer-events: none;
157
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
158
+ max-width: 90vw;
159
+ overflow: hidden;
160
+ text-overflow: ellipsis;
161
+ }
162
+
163
+ .${TOOLTIP_CLASS}--critical {
164
+ background: ${SEVERITY_COLORS.critical.border};
165
+ color: white;
166
+ }
167
+
168
+ .${TOOLTIP_CLASS}--serious {
169
+ background: ${SEVERITY_COLORS.serious.border};
170
+ color: white;
171
+ }
172
+
173
+ .${TOOLTIP_CLASS}--moderate {
174
+ background: ${SEVERITY_COLORS.moderate.border};
175
+ color: white;
176
+ }
177
+
178
+ .${TOOLTIP_CLASS}--minor {
179
+ background: ${SEVERITY_COLORS.minor.border};
180
+ color: white;
181
+ }
182
+ `
183
+ document.head.appendChild(style)
184
+ }
185
+
186
+ /**
187
+ * Calculate optimal tooltip position, ensuring it's always visible in viewport
188
+ */
189
+ function calculateTooltipPosition(
190
+ targetElement: Element,
191
+ tooltip: HTMLElement,
192
+ ): { top: number; left: number; flipped: boolean } {
193
+ const rect = targetElement.getBoundingClientRect()
194
+ const tooltipHeight = TOOLTIP_HEIGHT
195
+ const gap = 4 // Small gap between tooltip and element
196
+ const viewportPadding = 8 // Minimum distance from viewport edge
197
+
198
+ // Default: position above the element
199
+ let top = rect.top - tooltipHeight - gap
200
+ let flipped = false
201
+
202
+ // If tooltip would be above viewport, we need to find a visible position
203
+ if (top < viewportPadding) {
204
+ // Try positioning below the element's top edge (inside the element but visible)
205
+ const belowTop = rect.top + gap + viewportPadding
206
+
207
+ // If the element's bottom is within the viewport, position below the element
208
+ if (rect.bottom + gap + tooltipHeight < window.innerHeight) {
209
+ top = rect.bottom + gap
210
+ flipped = true
211
+ }
212
+ // Otherwise, position at the top of the viewport (for large elements like <main>)
213
+ else if (belowTop + tooltipHeight < window.innerHeight) {
214
+ top = belowTop
215
+ flipped = true
216
+ }
217
+ // Fallback: just keep it at the top of the viewport
218
+ else {
219
+ top = viewportPadding
220
+ flipped = true
221
+ }
222
+ }
223
+
224
+ // Also handle horizontal overflow - keep tooltip within viewport
225
+ let left = rect.left
226
+ const tooltipWidth = tooltip.offsetWidth || 150 // Estimate if not yet rendered
227
+ if (left + tooltipWidth > window.innerWidth) {
228
+ left = Math.max(
229
+ viewportPadding,
230
+ window.innerWidth - tooltipWidth - viewportPadding,
231
+ )
232
+ }
233
+ if (left < viewportPadding) {
234
+ left = viewportPadding
235
+ }
236
+
237
+ return { top, left, flipped }
238
+ }
239
+
240
+ /**
241
+ * Update all tooltip positions based on their target elements
242
+ */
243
+ function updateTooltipPositions(): void {
244
+ activeTooltips.forEach((targetElement, tooltip) => {
245
+ const { top, left } = calculateTooltipPosition(targetElement, tooltip)
246
+ tooltip.style.top = `${top}px`
247
+ tooltip.style.left = `${left}px`
248
+ })
249
+ }
250
+
251
+ /**
252
+ * Start listening for scroll events to update tooltip positions
253
+ */
254
+ function startScrollListener(): void {
255
+ if (scrollHandler) return
256
+
257
+ scrollHandler = () => {
258
+ requestAnimationFrame(updateTooltipPositions)
259
+ }
260
+
261
+ window.addEventListener('scroll', scrollHandler, true) // capture phase to catch all scrolls
262
+ }
263
+
264
+ /**
265
+ * Stop listening for scroll events
266
+ */
267
+ function stopScrollListener(): void {
268
+ if (scrollHandler) {
269
+ window.removeEventListener('scroll', scrollHandler, true)
270
+ scrollHandler = null
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Issue info for tooltip display
276
+ */
277
+ interface TooltipIssue {
278
+ ruleId: string
279
+ impact: SeverityThreshold
280
+ }
281
+
282
+ /**
283
+ * Create a tooltip element for issues and position it above the target element
284
+ */
285
+ function createTooltip(
286
+ issues: Array<TooltipIssue>,
287
+ targetElement: Element,
288
+ ): HTMLElement | null {
289
+ if (issues.length === 0) {
290
+ return null
291
+ }
292
+
293
+ // Sort issues by severity (most severe first)
294
+ const sortedIssues = [...issues].sort(
295
+ (a, b) => SEVERITY_ORDER[b.impact] - SEVERITY_ORDER[a.impact],
296
+ )
297
+
298
+ const firstIssue = sortedIssues[0]
299
+ if (!firstIssue) {
300
+ return null
301
+ }
302
+
303
+ const mostSevere = firstIssue.impact
304
+ const tooltip = document.createElement('div')
305
+ tooltip.className = `${TOOLTIP_CLASS} ${TOOLTIP_CLASS}--${mostSevere}`
306
+
307
+ // Build tooltip content showing all issues
308
+ if (sortedIssues.length === 1) {
309
+ tooltip.textContent = `${mostSevere.toUpperCase()}: ${firstIssue.ruleId}`
310
+ } else {
311
+ // Multiple issues - show count and list
312
+ const issueList = sortedIssues
313
+ .map(
314
+ (issue) => `${issue.impact.charAt(0).toUpperCase()}: ${issue.ruleId}`,
315
+ )
316
+ .join(' | ')
317
+ tooltip.textContent = `${sortedIssues.length} issues: ${issueList}`
318
+ }
319
+
320
+ // Mark as overlay element so it's excluded from a11y scans
321
+ tooltip.setAttribute('data-a11y-overlay', 'true')
322
+
323
+ // Track this tooltip for scroll updates (need to add before positioning)
324
+ activeTooltips.set(tooltip, targetElement)
325
+
326
+ // Start scroll listener if not already running
327
+ if (activeTooltips.size === 1) {
328
+ startScrollListener()
329
+ }
330
+
331
+ // Position the tooltip - will flip below element if above viewport
332
+ const { top, left } = calculateTooltipPosition(targetElement, tooltip)
333
+ tooltip.style.top = `${top}px`
334
+ tooltip.style.left = `${left}px`
335
+
336
+ return tooltip
337
+ }
338
+
339
+ /**
340
+ * Highlight a single element with the specified severity
341
+ */
342
+ export function highlightElement(
343
+ selector: string,
344
+ impact: SeverityThreshold = 'serious',
345
+ options: { showTooltip?: boolean; ruleId?: string } = {},
346
+ ): void {
347
+ const { showTooltip = true, ruleId } = options
348
+
349
+ try {
350
+ injectStyles()
351
+
352
+ const elements = document.querySelectorAll(selector)
353
+ if (elements.length === 0) {
354
+ console.warn(`[A11y Overlay] No elements found for selector: ${selector}`)
355
+ return
356
+ }
357
+
358
+ let highlightedCount = 0
359
+ elements.forEach((el) => {
360
+ // Skip elements inside devtools
361
+ if (isInsideDevtools(el)) {
362
+ return
363
+ }
364
+
365
+ el.classList.add(HIGHLIGHT_CLASS, `${HIGHLIGHT_CLASS}--${impact}`)
366
+
367
+ // Add tooltip to first highlighted element only
368
+ if (showTooltip && highlightedCount === 0 && ruleId) {
369
+ const tooltip = createTooltip([{ ruleId, impact }], el)
370
+ if (tooltip) {
371
+ document.body.appendChild(tooltip)
372
+ }
373
+ }
374
+
375
+ highlightedCount++
376
+ })
377
+
378
+ if (highlightedCount > 0) {
379
+ console.log(
380
+ `[A11y Overlay] Highlighted ${highlightedCount} element(s) with selector: ${selector}`,
381
+ )
382
+ }
383
+ } catch (error) {
384
+ console.error('[A11y Overlay] Error highlighting element:', error)
385
+ }
386
+ }
387
+
388
+ /**
389
+ * Highlight all elements with issues.
390
+ * Shows all issues per element in the tooltip, using the most severe for highlighting.
391
+ */
392
+ export function highlightAllIssues(issues: Array<A11yIssue>): void {
393
+ injectStyles()
394
+ clearHighlights()
395
+
396
+ // Track ALL issues for each selector
397
+ // Map: selector -> Array<{ ruleId, impact }>
398
+ const selectorIssues = new Map<string, Array<TooltipIssue>>()
399
+
400
+ // Collect all issues per selector
401
+ for (const issue of issues) {
402
+ for (const node of issue.nodes) {
403
+ const selector = node.selector
404
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
405
+ const impact = issue.impact ?? 'minor'
406
+
407
+ const existing = selectorIssues.get(selector) || []
408
+ // Avoid duplicate rule IDs for the same selector
409
+ if (!existing.some((e) => e.ruleId === issue.ruleId)) {
410
+ existing.push({ ruleId: issue.ruleId, impact })
411
+ selectorIssues.set(selector, existing)
412
+ }
413
+ }
414
+ }
415
+
416
+ // Highlight each selector with its most severe issue, but show all in tooltip
417
+ for (const [selector, issueList] of selectorIssues) {
418
+ // Skip empty lists (shouldn't happen, but guards against undefined)
419
+ if (issueList.length === 0) {
420
+ continue
421
+ }
422
+
423
+ // Find most severe impact for highlighting
424
+ const mostSevereImpact = issueList.reduce((max, issue) =>
425
+ SEVERITY_ORDER[issue.impact] > SEVERITY_ORDER[max.impact] ? issue : max,
426
+ ).impact
427
+
428
+ try {
429
+ const elements = document.querySelectorAll(selector)
430
+ if (elements.length === 0) {
431
+ continue
432
+ }
433
+
434
+ let highlightedCount = 0
435
+ elements.forEach((el) => {
436
+ // Skip elements inside devtools
437
+ if (isInsideDevtools(el)) {
438
+ return
439
+ }
440
+
441
+ el.classList.add(
442
+ HIGHLIGHT_CLASS,
443
+ `${HIGHLIGHT_CLASS}--${mostSevereImpact}`,
444
+ )
445
+
446
+ // Add tooltip to first highlighted element only, showing ALL issues
447
+ if (highlightedCount === 0) {
448
+ const tooltip = createTooltip(issueList, el)
449
+ if (tooltip) {
450
+ document.body.appendChild(tooltip)
451
+ }
452
+ }
453
+
454
+ highlightedCount++
455
+ })
456
+ } catch (error) {
457
+ console.error('[A11y Overlay] Error highlighting element:', error)
458
+ }
459
+ }
460
+ }
461
+
462
+ /**
463
+ * Clear all highlights from the page
464
+ */
465
+ export function clearHighlights(): void {
466
+ // Remove highlight classes
467
+ const highlighted = document.querySelectorAll(`.${HIGHLIGHT_CLASS}`)
468
+ highlighted.forEach((el) => {
469
+ el.classList.remove(
470
+ HIGHLIGHT_CLASS,
471
+ `${HIGHLIGHT_CLASS}--critical`,
472
+ `${HIGHLIGHT_CLASS}--serious`,
473
+ `${HIGHLIGHT_CLASS}--moderate`,
474
+ `${HIGHLIGHT_CLASS}--minor`,
475
+ )
476
+ })
477
+
478
+ // Remove tooltips and clear tracking
479
+ const tooltips = document.querySelectorAll(`.${TOOLTIP_CLASS}`)
480
+ tooltips.forEach((el) => el.remove())
481
+ activeTooltips.clear()
482
+ stopScrollListener()
483
+ }
@@ -0,0 +1,12 @@
1
+ import { createReactPanel } from '@tanstack/devtools-utils/react'
2
+ import { A11yDevtoolsCore } from '../core'
3
+
4
+ // type
5
+ import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/react'
6
+
7
+ export interface A11yDevtoolsReactInit extends DevtoolsPanelProps {}
8
+
9
+ const [A11yDevtoolsPanel, A11yDevtoolsPanelNoOp] =
10
+ createReactPanel(A11yDevtoolsCore)
11
+
12
+ export { A11yDevtoolsPanel, A11yDevtoolsPanelNoOp }
@@ -0,0 +1,16 @@
1
+ 'use client'
2
+
3
+ import * as Devtools from './A11yDevtools'
4
+ import * as plugin from './plugin'
5
+
6
+ export const A11yDevtoolsPanel =
7
+ process.env.NODE_ENV !== 'development'
8
+ ? Devtools.A11yDevtoolsPanelNoOp
9
+ : Devtools.A11yDevtoolsPanel
10
+
11
+ export const a11yDevtoolsPlugin =
12
+ process.env.NODE_ENV !== 'development'
13
+ ? plugin.a11yDevtoolsNoOpPlugin
14
+ : plugin.a11yDevtoolsPlugin
15
+
16
+ export type { A11yDevtoolsReactInit } from './A11yDevtools'
@@ -0,0 +1,9 @@
1
+ import { createReactPlugin } from '@tanstack/devtools-utils/react'
2
+ import { A11yDevtoolsPanel } from './A11yDevtools'
3
+
4
+ const [a11yDevtoolsPlugin, a11yDevtoolsNoOpPlugin] = createReactPlugin({
5
+ name: 'TanStack A11y',
6
+ Component: A11yDevtoolsPanel,
7
+ })
8
+
9
+ export { a11yDevtoolsPlugin, a11yDevtoolsNoOpPlugin }
@@ -0,0 +1,11 @@
1
+ import { createReactPanel } from '@tanstack/devtools-utils/react'
2
+ import { A11yDevtoolsCore } from '../../core'
3
+
4
+ // type
5
+ import type { DevtoolsPanelProps } from '@tanstack/devtools-utils/react'
6
+
7
+ export interface A11yDevtoolsReactInit extends DevtoolsPanelProps {}
8
+
9
+ const [A11yDevtoolsPanel] = createReactPanel(A11yDevtoolsCore)
10
+
11
+ export { A11yDevtoolsPanel }