@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.
- package/LICENSE +21 -0
- package/dist/esm/core/components/IssueCard.d.ts +10 -0
- package/dist/esm/core/components/IssueCard.js +83 -0
- package/dist/esm/core/components/IssueCard.js.map +1 -0
- package/dist/esm/core/components/IssueList.d.ts +6 -0
- package/dist/esm/core/components/IssueList.js +134 -0
- package/dist/esm/core/components/IssueList.js.map +1 -0
- package/dist/esm/core/components/Settings.d.ts +6 -0
- package/dist/esm/core/components/Settings.js +251 -0
- package/dist/esm/core/components/Settings.js.map +1 -0
- package/dist/esm/core/components/Shell.d.ts +2 -0
- package/dist/esm/core/components/Shell.js +214 -0
- package/dist/esm/core/components/Shell.js.map +1 -0
- package/dist/esm/core/components/index.d.ts +2 -0
- package/dist/esm/core/components/index.js +14 -0
- package/dist/esm/core/components/index.js.map +1 -0
- package/dist/esm/core/contexts/allyContext.d.ts +17 -0
- package/dist/esm/core/contexts/allyContext.js +66 -0
- package/dist/esm/core/contexts/allyContext.js.map +1 -0
- package/dist/esm/core/core.d.ts +19 -0
- package/dist/esm/core/core.js +8 -0
- package/dist/esm/core/core.js.map +1 -0
- package/dist/esm/core/index.d.ts +9 -0
- package/dist/esm/core/index.js +9 -0
- package/dist/esm/core/index.js.map +1 -0
- package/dist/esm/core/production.d.ts +2 -0
- package/dist/esm/core/production.js +4 -0
- package/dist/esm/core/styles/styles.d.ts +85 -0
- package/dist/esm/core/styles/styles.js +547 -0
- package/dist/esm/core/styles/styles.js.map +1 -0
- package/dist/esm/core/types/types.d.ts +141 -0
- package/dist/esm/core/utils/ally-audit.utils.d.ts +19 -0
- package/dist/esm/core/utils/ally-audit.utils.js +226 -0
- package/dist/esm/core/utils/ally-audit.utils.js.map +1 -0
- package/dist/esm/core/utils/config.utils.d.ts +17 -0
- package/dist/esm/core/utils/config.utils.js +63 -0
- package/dist/esm/core/utils/config.utils.js.map +1 -0
- package/dist/esm/core/utils/custom-audit.utils.d.ts +13 -0
- package/dist/esm/core/utils/custom-audit.utils.js +426 -0
- package/dist/esm/core/utils/custom-audit.utils.js.map +1 -0
- package/dist/esm/core/utils/export-audit.uitls.d.ts +17 -0
- package/dist/esm/core/utils/export-audit.uitls.js +83 -0
- package/dist/esm/core/utils/export-audit.uitls.js.map +1 -0
- package/dist/esm/core/utils/ui.utils.d.ts +24 -0
- package/dist/esm/core/utils/ui.utils.js +330 -0
- package/dist/esm/core/utils/ui.utils.js.map +1 -0
- package/dist/esm/react/A11yDevtools.d.ts +5 -0
- package/dist/esm/react/A11yDevtools.js +8 -0
- package/dist/esm/react/A11yDevtools.js.map +1 -0
- package/dist/esm/react/index.d.ts +8 -0
- package/dist/esm/react/index.js +11 -0
- package/dist/esm/react/index.js.map +1 -0
- package/dist/esm/react/plugin.d.ts +12 -0
- package/dist/esm/react/plugin.js +11 -0
- package/dist/esm/react/plugin.js.map +1 -0
- package/dist/esm/react/production/A11yDevtools.d.ts +5 -0
- package/dist/esm/react/production/A11yDevtools.js +8 -0
- package/dist/esm/react/production/A11yDevtools.js.map +1 -0
- package/dist/esm/react/production/plugin.d.ts +7 -0
- package/dist/esm/react/production/plugin.js +11 -0
- package/dist/esm/react/production/plugin.js.map +1 -0
- package/dist/esm/react/production.d.ts +3 -0
- package/dist/esm/react/production.js +5 -0
- package/dist/esm/solid/A11yDevtools.d.ts +5 -0
- package/dist/esm/solid/A11yDevtools.js +8 -0
- package/dist/esm/solid/A11yDevtools.js.map +1 -0
- package/dist/esm/solid/index.d.ts +8 -0
- package/dist/esm/solid/index.js +9 -0
- package/dist/esm/solid/index.js.map +1 -0
- package/dist/esm/solid/plugin.d.ts +12 -0
- package/dist/esm/solid/plugin.js +11 -0
- package/dist/esm/solid/plugin.js.map +1 -0
- package/dist/esm/solid/production/A11yDevtools.d.ts +5 -0
- package/dist/esm/solid/production/A11yDevtools.js +8 -0
- package/dist/esm/solid/production/A11yDevtools.js.map +1 -0
- package/dist/esm/solid/production/plugin.d.ts +7 -0
- package/dist/esm/solid/production/plugin.js +11 -0
- package/dist/esm/solid/production/plugin.js.map +1 -0
- package/dist/esm/solid/production.d.ts +3 -0
- package/dist/esm/solid/production.js +3 -0
- package/package.json +110 -7
- package/src/core/components/IssueCard.tsx +75 -0
- package/src/core/components/IssueList.tsx +155 -0
- package/src/core/components/Settings.tsx +221 -0
- package/src/core/components/Shell.tsx +154 -0
- package/src/core/components/index.tsx +12 -0
- package/src/core/contexts/allyContext.tsx +118 -0
- package/src/core/core.tsx +11 -0
- package/src/core/index.ts +10 -0
- package/src/core/production.ts +5 -0
- package/src/core/styles/styles.ts +556 -0
- package/src/core/types/types.ts +177 -0
- package/src/core/utils/ally-audit.utils.ts +345 -0
- package/src/core/utils/config.utils.ts +68 -0
- package/src/core/utils/custom-audit.utils.ts +643 -0
- package/src/core/utils/export-audit.uitls.ts +180 -0
- package/src/core/utils/ui.utils.ts +483 -0
- package/src/react/A11yDevtools.ts +12 -0
- package/src/react/index.ts +16 -0
- package/src/react/plugin.ts +9 -0
- package/src/react/production/A11yDevtools.ts +11 -0
- package/src/react/production/plugin.ts +9 -0
- package/src/react/production.ts +7 -0
- package/src/solid/A11yDevtools.ts +11 -0
- package/src/solid/index.ts +14 -0
- package/src/solid/plugin.ts +9 -0
- package/src/solid/production/A11yDevtools.ts +10 -0
- package/src/solid/production/plugin.ts +9 -0
- package/src/solid/production.ts +5 -0
- 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 }
|