@a11yfred/neighbor 1.0.4 → 2.0.0
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/CHANGELOG.md +88 -71
- package/CONTRIBUTING.md +41 -41
- package/README.md +4 -466
- package/RULES-CONTENT.md +245 -86
- package/RULES-CSS.md +41 -19
- package/RULES-MARKUP.md +168 -93
- package/RULES.md +47 -28
- package/lib/content-rules.js +216 -0
- package/lib/framework-rules.js +282 -0
- package/lib/helpers-webcomponents.js +134 -0
- package/lib/rules.js +376 -11
- package/lib/ulam-rules.js +145 -10
- package/neighbor-content.mjs +1 -1
- package/neighbor-eslint-angular.mjs +29 -8
- package/neighbor-eslint-lit.mjs +85 -0
- package/neighbor-eslint-remix3.mjs +49 -0
- package/neighbor-eslint-vue.mjs +26 -6
- package/neighbor-eslint-webcomponents.mjs +41 -0
- package/neighbor-eslint.mjs +13 -6
- package/neighbor-stylelint.mjs +141 -3
- package/package.json +10 -10
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* neighbor/lib/helpers-webcomponents.js
|
|
3
|
+
* Helpers for Vanilla Web Components and static HTML AST via @html-eslint/parser.
|
|
4
|
+
*
|
|
5
|
+
* Parser: @html-eslint/parser provides a generic HTML AST.
|
|
6
|
+
* Node type for elements: 'Tag'
|
|
7
|
+
* The node has:
|
|
8
|
+
* node.name - tag name string
|
|
9
|
+
* node.attributes - Attribute[]
|
|
10
|
+
* node.children - Array of Tag | Text
|
|
11
|
+
* node.parent - parent Tag
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
INTERACTIVE_ELEMENTS,
|
|
16
|
+
INTERACTIVE_ROLES,
|
|
17
|
+
} from './helpers.js'
|
|
18
|
+
|
|
19
|
+
export function getAttr(node, name) {
|
|
20
|
+
return (node.attributes ?? []).find(a => a.key?.value === name) ?? null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getAttrStringValue(attr) {
|
|
24
|
+
if (!attr) return null
|
|
25
|
+
return typeof attr.value?.value === 'string' ? attr.value.value : null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getElementName(node) {
|
|
29
|
+
const raw = node.name ?? ''
|
|
30
|
+
if (!raw) return null
|
|
31
|
+
// HTML tags are case-insensitive, but Web Component tags usually have a dash.
|
|
32
|
+
return raw.toLowerCase()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function hasAttr(node, name) {
|
|
36
|
+
return getAttr(node, name) !== null
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function getRoleValue(node) {
|
|
40
|
+
return getAttrStringValue(getAttr(node, 'role'))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function hasAccessibleName(node) {
|
|
44
|
+
return hasAttr(node, 'aria-label') || hasAttr(node, 'aria-labelledby')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function isInteractiveElement(node) {
|
|
48
|
+
const el = getElementName(node)
|
|
49
|
+
if (el && INTERACTIVE_ELEMENTS.has(el)) return true
|
|
50
|
+
const role = getRoleValue(node)
|
|
51
|
+
return !!(role && INTERACTIVE_ROLES.has(role))
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function hasOnlyHiddenChildren(node) {
|
|
55
|
+
const children = node.children ?? []
|
|
56
|
+
if (children.length === 0) return false
|
|
57
|
+
return children.every(child => {
|
|
58
|
+
if (child.type === 'Text') return (child.value ?? '').trim() === ''
|
|
59
|
+
if (child.type === 'Tag') {
|
|
60
|
+
return (child.attributes ?? []).some(a => a.key?.value === 'aria-hidden')
|
|
61
|
+
}
|
|
62
|
+
return false
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const NEW_TAB_PATTERN = /new.tab|new.window|opens in/i
|
|
67
|
+
|
|
68
|
+
function collectHtmlText(node) {
|
|
69
|
+
if (!node) return ''
|
|
70
|
+
if (node.type === 'Text') return node.value ?? ''
|
|
71
|
+
if (node.type === 'Tag') return (node.children ?? []).map(collectHtmlText).join('')
|
|
72
|
+
return ''
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function hasNewTabWarning(node) {
|
|
76
|
+
const labelVal = (getAttrStringValue(getAttr(node, 'aria-label')) ?? '').toLowerCase()
|
|
77
|
+
if (NEW_TAB_PATTERN.test(labelVal)) return true
|
|
78
|
+
const childText = (node.children ?? []).map(collectHtmlText).join('')
|
|
79
|
+
return NEW_TAB_PATTERN.test(childText)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function getParent(node) {
|
|
83
|
+
return node.parent?.type === 'Tag' ? node.parent : null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function* getAncestors(node) {
|
|
87
|
+
let cur = getParent(node)
|
|
88
|
+
while (cur) {
|
|
89
|
+
yield cur
|
|
90
|
+
cur = getParent(cur)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function* getChildOpeningElements(node) {
|
|
95
|
+
for (const child of node.children ?? []) {
|
|
96
|
+
if (child.type === 'Tag') yield child
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getClassName(node) {
|
|
101
|
+
return getAttrStringValue(getAttr(node, 'class'))
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function getInnerHtmlAttr(node) {
|
|
105
|
+
// Vanilla HTML has no innerHTML attribute directive
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getInnerHtmlAttrName(_node) {
|
|
110
|
+
return null
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const h = {
|
|
114
|
+
getAttr,
|
|
115
|
+
getAttrStringValue,
|
|
116
|
+
getElementName,
|
|
117
|
+
hasAttr,
|
|
118
|
+
getRoleValue,
|
|
119
|
+
hasAccessibleName,
|
|
120
|
+
isInteractiveElement,
|
|
121
|
+
hasOnlyHiddenChildren,
|
|
122
|
+
hasNewTabWarning,
|
|
123
|
+
getParent,
|
|
124
|
+
getAncestors,
|
|
125
|
+
getChildOpeningElements,
|
|
126
|
+
getClassName,
|
|
127
|
+
getInnerHtmlAttr,
|
|
128
|
+
getInnerHtmlAttrName,
|
|
129
|
+
elementVisitor: 'Tag',
|
|
130
|
+
elementWithChildrenVisitor: 'Tag',
|
|
131
|
+
getOpeningElement: (node) => node,
|
|
132
|
+
getChildOpeningElementsFromWrapper: (node) =>
|
|
133
|
+
(node.children ?? []).filter(c => c.type === 'Tag'),
|
|
134
|
+
}
|
package/lib/rules.js
CHANGED
|
@@ -579,18 +579,15 @@ export function makeWarnRoleAlert(h) {
|
|
|
579
579
|
|
|
580
580
|
// ─── prefer-aria-disabled ────────────────────────────────────────────────────
|
|
581
581
|
|
|
582
|
-
//
|
|
583
|
-
// not the right substitute for these; native disabled is correct and expected.
|
|
584
|
-
const NATIVE_DISABLED_ELEMENTS = new Set(['input', 'select', 'textarea', 'option', 'optgroup', 'fieldset'])
|
|
585
|
-
|
|
582
|
+
// Prefer aria-disabled over HTML disabled for all interactive elements
|
|
586
583
|
export function makePreferAriaDisabled(h) {
|
|
587
584
|
return {
|
|
588
585
|
meta: {
|
|
589
586
|
type: 'suggestion',
|
|
590
|
-
docs: { description: '
|
|
587
|
+
docs: { description: 'Prefer aria-disabled over HTML disabled attribute for consistent AT handling' },
|
|
591
588
|
messages: {
|
|
592
589
|
disabled:
|
|
593
|
-
'`disabled` removes the element from the tab order - keyboard and AT users cannot discover it or learn why it\'s unavailable.
|
|
590
|
+
'`disabled` removes the element from the tab order - keyboard and AT users cannot discover it or learn why it\'s unavailable. Use aria-disabled="true" instead, which keeps the element reachable and lets you explain the reason. For form controls, use aria-disabled on the control itself, not the native disabled attribute. (Roselli: Don\'t Disable Form Controls)',
|
|
594
591
|
},
|
|
595
592
|
schema: [],
|
|
596
593
|
},
|
|
@@ -598,9 +595,6 @@ export function makePreferAriaDisabled(h) {
|
|
|
598
595
|
return {
|
|
599
596
|
[h.elementVisitor](node) {
|
|
600
597
|
if (!h.isInteractiveElement(node)) return
|
|
601
|
-
// Native form controls: HTML disabled is correct per spec, not aria-disabled
|
|
602
|
-
const elName = h.getElementName(node)
|
|
603
|
-
if (elName && NATIVE_DISABLED_ELEMENTS.has(elName)) return
|
|
604
598
|
const attr = h.getAttr(node, 'disabled')
|
|
605
599
|
if (!attr) return
|
|
606
600
|
// Only flag boolean disabled (not disabled={false})
|
|
@@ -1669,7 +1663,7 @@ export function makeNoNoninteractiveTabindex(h) {
|
|
|
1669
1663
|
const ROLE_TO_ELEMENT = {
|
|
1670
1664
|
button: 'button',
|
|
1671
1665
|
link: 'a',
|
|
1672
|
-
heading: 'h1
|
|
1666
|
+
heading: 'h1-h6',
|
|
1673
1667
|
checkbox: 'input[type=checkbox]',
|
|
1674
1668
|
radio: 'input[type=radio]',
|
|
1675
1669
|
textbox: 'input or textarea',
|
|
@@ -2271,9 +2265,350 @@ function isEffectivelyEmpty(wrapperNode, h) {
|
|
|
2271
2265
|
})
|
|
2272
2266
|
}
|
|
2273
2267
|
|
|
2268
|
+
// ─── no-skipped-heading-levels ───────────────────────────────────────────────
|
|
2269
|
+
|
|
2270
|
+
export function makeNoSkippedHeadingLevels(h) {
|
|
2271
|
+
return {
|
|
2272
|
+
meta: {
|
|
2273
|
+
type: 'suggestion',
|
|
2274
|
+
docs: { description: 'Disallow skipping heading levels (e.g. <h1> to <h3>)' },
|
|
2275
|
+
messages: {
|
|
2276
|
+
skippedHeading: 'Skipped heading level. Do not jump from <h{{prev}}> to <h{{current}}>. (Axe: heading-order)',
|
|
2277
|
+
},
|
|
2278
|
+
schema: [],
|
|
2279
|
+
},
|
|
2280
|
+
create(context) {
|
|
2281
|
+
let prevLevel = null
|
|
2282
|
+
return {
|
|
2283
|
+
[h.elementVisitor](node) {
|
|
2284
|
+
const el = h.getElementName(node)
|
|
2285
|
+
const role = h.getRoleValue(node)
|
|
2286
|
+
let currentLevel = null
|
|
2287
|
+
|
|
2288
|
+
if (el && el.match(/^h[1-6]$/i) && role !== 'presentation' && role !== 'none') {
|
|
2289
|
+
currentLevel = parseInt(el.charAt(1), 10)
|
|
2290
|
+
}
|
|
2291
|
+
|
|
2292
|
+
const ariaLevelAttr = h.getAttr(node, 'aria-level')
|
|
2293
|
+
const ariaLevel = ariaLevelAttr ? h.getAttrStringValue(ariaLevelAttr) : null
|
|
2294
|
+
if (ariaLevel && !isNaN(parseInt(ariaLevel, 10))) {
|
|
2295
|
+
currentLevel = parseInt(ariaLevel, 10)
|
|
2296
|
+
}
|
|
2297
|
+
|
|
2298
|
+
if (currentLevel !== null) {
|
|
2299
|
+
if (prevLevel !== null && currentLevel > prevLevel + 1) {
|
|
2300
|
+
context.report({
|
|
2301
|
+
node,
|
|
2302
|
+
messageId: 'skippedHeading',
|
|
2303
|
+
data: { prev: prevLevel, current: currentLevel }
|
|
2304
|
+
})
|
|
2305
|
+
}
|
|
2306
|
+
prevLevel = currentLevel
|
|
2307
|
+
}
|
|
2308
|
+
},
|
|
2309
|
+
}
|
|
2310
|
+
},
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
|
|
2314
|
+
// ─── no-multiple-main ────────────────────────────────────────────────────────
|
|
2315
|
+
|
|
2316
|
+
export function makeNoMultipleMain(h) {
|
|
2317
|
+
return {
|
|
2318
|
+
meta: {
|
|
2319
|
+
type: 'problem',
|
|
2320
|
+
docs: { description: 'Disallow more than one <main> or role="main" element per file' },
|
|
2321
|
+
messages: {
|
|
2322
|
+
multipleMain: 'A document must not have more than one <main> or role="main" visible. (Axe: landmark-one-main)',
|
|
2323
|
+
},
|
|
2324
|
+
schema: [],
|
|
2325
|
+
},
|
|
2326
|
+
create(context) {
|
|
2327
|
+
let mainCount = 0
|
|
2328
|
+
return {
|
|
2329
|
+
[h.elementVisitor](node) {
|
|
2330
|
+
const role = h.getRoleValue(node)
|
|
2331
|
+
const el = h.getElementName(node)
|
|
2332
|
+
if (role === 'main' || (el === 'main' && role !== 'presentation' && role !== 'none')) {
|
|
2333
|
+
mainCount++
|
|
2334
|
+
if (mainCount > 1) {
|
|
2335
|
+
context.report({ node, messageId: 'multipleMain' })
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
},
|
|
2339
|
+
}
|
|
2340
|
+
},
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
// ─── no-toggle-without-checked ───────────────────────────────────────────────
|
|
2345
|
+
|
|
2346
|
+
export function makeNoToggleWithoutChecked(h) {
|
|
2347
|
+
return {
|
|
2348
|
+
meta: {
|
|
2349
|
+
type: 'problem',
|
|
2350
|
+
docs: { description: 'Disallow role="switch", "checkbox", or "radio" without aria-checked' },
|
|
2351
|
+
messages: {
|
|
2352
|
+
missingChecked: 'Elements with role="{{role}}" must have an aria-checked attribute to convey their state. (APG)',
|
|
2353
|
+
},
|
|
2354
|
+
schema: [],
|
|
2355
|
+
},
|
|
2356
|
+
create(context) {
|
|
2357
|
+
return {
|
|
2358
|
+
[h.elementVisitor](node) {
|
|
2359
|
+
const role = h.getRoleValue(node)
|
|
2360
|
+
if (role !== 'switch' && role !== 'checkbox' && role !== 'radio') return
|
|
2361
|
+
|
|
2362
|
+
if (h.hasAttr(node, 'aria-checked')) return
|
|
2363
|
+
|
|
2364
|
+
// Exception: HTML native <input type="checkbox/radio"> has native state
|
|
2365
|
+
const el = h.getElementName(node)
|
|
2366
|
+
if (el === 'input') {
|
|
2367
|
+
const typeAttr = h.getAttr(node, 'type')
|
|
2368
|
+
const type = typeAttr ? h.getAttrStringValue(typeAttr) : null
|
|
2369
|
+
if (type === 'checkbox' || type === 'radio') return
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
context.report({ node, messageId: 'missingChecked', data: { role } })
|
|
2373
|
+
},
|
|
2374
|
+
}
|
|
2375
|
+
},
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
// ─── no-expanded-without-controls ────────────────────────────────────────────
|
|
2380
|
+
|
|
2381
|
+
export function makeNoExpandedWithoutControls(h) {
|
|
2382
|
+
return {
|
|
2383
|
+
meta: {
|
|
2384
|
+
type: 'suggestion',
|
|
2385
|
+
docs: { description: 'Disallow aria-expanded without aria-controls' },
|
|
2386
|
+
messages: {
|
|
2387
|
+
missingControls: 'Elements with aria-expanded must have an aria-controls attribute pointing to the ID of the expandable container. (APG: Disclosure)',
|
|
2388
|
+
},
|
|
2389
|
+
schema: [],
|
|
2390
|
+
},
|
|
2391
|
+
create(context) {
|
|
2392
|
+
return {
|
|
2393
|
+
[h.elementVisitor](node) {
|
|
2394
|
+
if (!h.hasAttr(node, 'aria-expanded')) return
|
|
2395
|
+
if (h.hasAttr(node, 'aria-controls')) return
|
|
2396
|
+
|
|
2397
|
+
context.report({ node, messageId: 'missingControls' })
|
|
2398
|
+
},
|
|
2399
|
+
}
|
|
2400
|
+
},
|
|
2401
|
+
}
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
// ─── no-aria-hidden-on-main ──────────────────────────────────────────────────
|
|
2405
|
+
|
|
2406
|
+
export function makeNoAriaHiddenOnMain(h) {
|
|
2407
|
+
return {
|
|
2408
|
+
meta: {
|
|
2409
|
+
type: 'problem',
|
|
2410
|
+
docs: { description: 'Disallow aria-hidden="true" on <body>, <main>, or role="main"' },
|
|
2411
|
+
messages: {
|
|
2412
|
+
hiddenMain: 'aria-hidden="true" on the main content or body hides the entire application from screen readers. Place it on a sibling wrapper instead. (APG)',
|
|
2413
|
+
},
|
|
2414
|
+
schema: [],
|
|
2415
|
+
},
|
|
2416
|
+
create(context) {
|
|
2417
|
+
return {
|
|
2418
|
+
[h.elementVisitor](node) {
|
|
2419
|
+
const isHidden = h.getAttrStringValue(h.getAttr(node, 'aria-hidden')) === 'true'
|
|
2420
|
+
if (!isHidden) return
|
|
2421
|
+
|
|
2422
|
+
const el = h.getElementName(node)
|
|
2423
|
+
const role = h.getRoleValue(node)
|
|
2424
|
+
|
|
2425
|
+
if (el === 'main' || el === 'body' || role === 'main') {
|
|
2426
|
+
context.report({ node, messageId: 'hiddenMain' })
|
|
2427
|
+
}
|
|
2428
|
+
},
|
|
2429
|
+
}
|
|
2430
|
+
},
|
|
2431
|
+
}
|
|
2432
|
+
}
|
|
2433
|
+
|
|
2434
|
+
// ─── no-meter-without-valuenow ───────────────────────────────────────────────
|
|
2435
|
+
|
|
2436
|
+
export function makeNoMeterWithoutValuenow(h) {
|
|
2437
|
+
return {
|
|
2438
|
+
meta: {
|
|
2439
|
+
type: 'problem',
|
|
2440
|
+
docs: { description: 'Disallow role="meter" without aria-valuenow' },
|
|
2441
|
+
messages: {
|
|
2442
|
+
missingValuenow: 'role="meter" represents a scalar measurement and must have an aria-valuenow attribute. (APG: Meter)',
|
|
2443
|
+
},
|
|
2444
|
+
schema: [],
|
|
2445
|
+
},
|
|
2446
|
+
create(context) {
|
|
2447
|
+
return {
|
|
2448
|
+
[h.elementVisitor](node) {
|
|
2449
|
+
const role = h.getRoleValue(node)
|
|
2450
|
+
if (role !== 'meter') return
|
|
2451
|
+
if (h.hasAttr(node, 'aria-valuenow')) return
|
|
2452
|
+
|
|
2453
|
+
context.report({ node, messageId: 'missingValuenow' })
|
|
2454
|
+
},
|
|
2455
|
+
}
|
|
2456
|
+
},
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
|
|
2460
|
+
// ─── vue-transition-live-region ──────────────────────────────────────────────
|
|
2461
|
+
|
|
2462
|
+
export function makeVueTransitionLiveRegion(h) {
|
|
2463
|
+
return {
|
|
2464
|
+
meta: {
|
|
2465
|
+
type: 'suggestion',
|
|
2466
|
+
docs: { description: 'Require aria-live on <Transition>/<TransitionGroup> containing dynamic text' },
|
|
2467
|
+
messages: {
|
|
2468
|
+
missingLive:
|
|
2469
|
+
'<{{tag}}> renders content dynamically but has no aria-live region wrapper. Screen readers will not announce the new content. Wrap it in an element with aria-live="polite". (WCAG SC 4.1.3)',
|
|
2470
|
+
},
|
|
2471
|
+
schema: [],
|
|
2472
|
+
},
|
|
2473
|
+
create(context) {
|
|
2474
|
+
return {
|
|
2475
|
+
[h.elementVisitor](node) {
|
|
2476
|
+
const raw = node.rawName ?? node.name ?? ''
|
|
2477
|
+
if (raw !== 'Transition' && raw !== 'TransitionGroup' && raw !== 'transition' && raw !== 'transition-group') return
|
|
2478
|
+
|
|
2479
|
+
if (h.hasAttr(node, 'aria-live')) return
|
|
2480
|
+
let ancestor = typeof h.getParent === 'function' ? h.getParent(node) : null
|
|
2481
|
+
while (ancestor) {
|
|
2482
|
+
if (h.hasAttr(ancestor, 'aria-live')) return
|
|
2483
|
+
ancestor = typeof h.getParent === 'function' ? h.getParent(ancestor) : null
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
const children = node.children ?? []
|
|
2487
|
+
const hasContent = children.some(c => {
|
|
2488
|
+
if (c.type === 'VText') return (c.value ?? '').trim().length > 0
|
|
2489
|
+
if (c.type === 'VElement') return true
|
|
2490
|
+
return false
|
|
2491
|
+
})
|
|
2492
|
+
if (!hasContent) return
|
|
2493
|
+
|
|
2494
|
+
context.report({ node, messageId: 'missingLive', data: { tag: raw } })
|
|
2495
|
+
},
|
|
2496
|
+
}
|
|
2497
|
+
},
|
|
2498
|
+
}
|
|
2499
|
+
}
|
|
2500
|
+
|
|
2501
|
+
// ─── vue-click-key-events ────────────────────────────────────────────────────
|
|
2502
|
+
|
|
2503
|
+
export function makeVueClickKeyEvents(_h) {
|
|
2504
|
+
return {
|
|
2505
|
+
meta: {
|
|
2506
|
+
type: 'problem',
|
|
2507
|
+
docs: { description: 'Require keyboard event handlers alongside @click on non-native elements' },
|
|
2508
|
+
messages: {
|
|
2509
|
+
missingKeyboard:
|
|
2510
|
+
'Element has @click but no @keyup.enter or @keydown.space. Vue does not polyfill keyboard events on custom elements - keyboard-only users cannot activate it. Add @keyup.enter and @keydown.space handlers. (WCAG SC 2.1.1)',
|
|
2511
|
+
},
|
|
2512
|
+
schema: [],
|
|
2513
|
+
},
|
|
2514
|
+
create(context) {
|
|
2515
|
+
return {
|
|
2516
|
+
VAttribute(node) {
|
|
2517
|
+
if (!node.directive) return
|
|
2518
|
+
|
|
2519
|
+
const keyName = node.key?.name
|
|
2520
|
+
const argName = node.key?.argument?.name ?? node.key?.argument?.value
|
|
2521
|
+
if (keyName !== 'on' || argName !== 'click') return
|
|
2522
|
+
|
|
2523
|
+
const vElement = node.parent?.parent ?? node.parent
|
|
2524
|
+
if (!vElement || vElement.type !== 'VElement') return
|
|
2525
|
+
|
|
2526
|
+
const el = (vElement.rawName ?? vElement.name ?? '').toLowerCase()
|
|
2527
|
+
if (['button', 'a', 'input', 'select', 'textarea', 'summary'].includes(el)) return
|
|
2528
|
+
|
|
2529
|
+
const attrs = vElement.startTag?.attributes ?? []
|
|
2530
|
+
const hasKeyboard = attrs.some(a => {
|
|
2531
|
+
if (!a.directive) return false
|
|
2532
|
+
const k = a.key?.name
|
|
2533
|
+
const arg = a.key?.argument?.name ?? a.key?.argument?.value ?? ''
|
|
2534
|
+
const modifiers = a.key?.modifiers ?? []
|
|
2535
|
+
if (k !== 'on') return false
|
|
2536
|
+
if (arg === 'keyup' && modifiers.includes('enter')) return true
|
|
2537
|
+
if (arg === 'keydown' && (modifiers.includes('space') || modifiers.includes('enter'))) return true
|
|
2538
|
+
if (arg === 'keypress') return true
|
|
2539
|
+
return false
|
|
2540
|
+
})
|
|
2541
|
+
|
|
2542
|
+
if (!hasKeyboard) {
|
|
2543
|
+
context.report({ node, messageId: 'missingKeyboard' })
|
|
2544
|
+
}
|
|
2545
|
+
},
|
|
2546
|
+
}
|
|
2547
|
+
},
|
|
2548
|
+
}
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
// ─── react-fragment-ruins-aria ───────────────────────────────────────────────
|
|
2552
|
+
|
|
2553
|
+
const ARIA_CHILD_CONSTRAINED_ROLES = new Set([
|
|
2554
|
+
'list', 'listbox', 'menu', 'menubar', 'radiogroup', 'tree',
|
|
2555
|
+
'grid', 'rowgroup', 'row', 'tablist',
|
|
2556
|
+
])
|
|
2557
|
+
const NATIVE_CHILD_CONSTRAINED = new Set([
|
|
2558
|
+
'ul', 'ol', 'dl', 'table', 'thead', 'tbody', 'tfoot', 'tr', 'select',
|
|
2559
|
+
])
|
|
2560
|
+
|
|
2561
|
+
export function makeReactFragmentRuinsAria(h) {
|
|
2562
|
+
return {
|
|
2563
|
+
meta: {
|
|
2564
|
+
type: 'suggestion',
|
|
2565
|
+
docs: { description: 'Warn when a <div key={...}> in a map breaks required ARIA parent-child relationships' },
|
|
2566
|
+
messages: {
|
|
2567
|
+
useFragment:
|
|
2568
|
+
'A <div> with only a `key` prop inside a constrained ARIA container breaks the required DOM hierarchy. Use <React.Fragment key={...}> instead. (ARIA 1.2)',
|
|
2569
|
+
},
|
|
2570
|
+
schema: [],
|
|
2571
|
+
},
|
|
2572
|
+
create(context) {
|
|
2573
|
+
return {
|
|
2574
|
+
[h.elementVisitor](node) {
|
|
2575
|
+
const el = h.getElementName(node)
|
|
2576
|
+
if (el !== 'div' && el !== 'span') return
|
|
2577
|
+
|
|
2578
|
+
const attrs = node.openingElement?.attributes ?? node.startTag?.attributes ?? []
|
|
2579
|
+
const nonKeyAttrs = attrs.filter(a => {
|
|
2580
|
+
const name = a.name?.name ?? a.name ?? a.key?.name
|
|
2581
|
+
return name !== 'key'
|
|
2582
|
+
})
|
|
2583
|
+
if (nonKeyAttrs.length > 0) return
|
|
2584
|
+
|
|
2585
|
+
const parent = typeof h.getParent === 'function' ? h.getParent(node) : null
|
|
2586
|
+
if (!parent) return
|
|
2587
|
+
|
|
2588
|
+
const parentEl = h.getElementName(parent)
|
|
2589
|
+
const parentRole = h.getRoleValue(parent)
|
|
2590
|
+
|
|
2591
|
+
if (
|
|
2592
|
+
(parentRole && ARIA_CHILD_CONSTRAINED_ROLES.has(parentRole)) ||
|
|
2593
|
+
(parentEl && NATIVE_CHILD_CONSTRAINED.has(parentEl))
|
|
2594
|
+
) {
|
|
2595
|
+
context.report({ node, messageId: 'useFragment' })
|
|
2596
|
+
}
|
|
2597
|
+
},
|
|
2598
|
+
}
|
|
2599
|
+
},
|
|
2600
|
+
}
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2274
2603
|
// ─── All rules map ────────────────────────────────────────────────────────────
|
|
2275
2604
|
|
|
2276
2605
|
export const RULE_FACTORIES = {
|
|
2606
|
+
'no-toggle-without-checked': makeNoToggleWithoutChecked,
|
|
2607
|
+
'no-expanded-without-controls': makeNoExpandedWithoutControls,
|
|
2608
|
+
'no-aria-hidden-on-main': makeNoAriaHiddenOnMain,
|
|
2609
|
+
'no-meter-without-valuenow': makeNoMeterWithoutValuenow,
|
|
2610
|
+
'no-skipped-heading-levels': makeNoSkippedHeadingLevels,
|
|
2611
|
+
'no-multiple-main': makeNoMultipleMain,
|
|
2277
2612
|
'no-aria-label-on-generic': makeNoAriaLabelOnGeneric,
|
|
2278
2613
|
'no-assertive-live-overuse': makeNoAssertiveLiveOveruse,
|
|
2279
2614
|
'warn-role-alert': makeWarnRoleAlert,
|
|
@@ -2336,6 +2671,13 @@ export const RULE_FACTORIES = {
|
|
|
2336
2671
|
'no-dynamic-content-without-live': makeNoDynamicContentWithoutLive,
|
|
2337
2672
|
'form-field-multiple-labels': makeFormFieldMultipleLabels,
|
|
2338
2673
|
'no-empty-table-header': makeNoEmptyTableHeader,
|
|
2674
|
+
// Vue-specific template rules - included in Vue config only
|
|
2675
|
+
'vue-transition-live-region': makeVueTransitionLiveRegion,
|
|
2676
|
+
'vue-click-key-events': makeVueClickKeyEvents,
|
|
2677
|
+
'vue-router-focus-management': makeVueRouterFocusManagement,
|
|
2678
|
+
// React-specific JSX rules - included in React/Remix configs only
|
|
2679
|
+
'react-fragment-ruins-aria': makeReactFragmentRuinsAria,
|
|
2680
|
+
'react-spa-focus-management': makeReactSpaFocusManagement,
|
|
2339
2681
|
}
|
|
2340
2682
|
|
|
2341
2683
|
/** Build the rules map for a plugin by applying helpers to all factories. */
|
|
@@ -2356,10 +2698,16 @@ export function buildRecommendedRules(ns) {
|
|
|
2356
2698
|
[`${ns}/no-unblocked-aria-disabled`]: 'error',
|
|
2357
2699
|
[`${ns}/no-roles-without-name`]: 'error',
|
|
2358
2700
|
[`${ns}/no-group-without-name`]: 'error',
|
|
2701
|
+
[`${ns}/no-toggle-without-checked`]: 'error',
|
|
2702
|
+
[`${ns}/no-aria-hidden-on-main`]: 'error',
|
|
2703
|
+
[`${ns}/no-meter-without-valuenow`]: 'error',
|
|
2704
|
+
[`${ns}/no-expanded-without-controls`]: 'warn',
|
|
2705
|
+
[`${ns}/no-skipped-heading-levels`]: 'warn',
|
|
2706
|
+
[`${ns}/no-multiple-main`]: 'warn',
|
|
2359
2707
|
[`${ns}/no-presentation-on-focusable`]: 'error',
|
|
2360
2708
|
[`${ns}/no-log-with-interactive-children`]: 'error',
|
|
2361
2709
|
[`${ns}/no-aria-hidden-in-link`]: 'error',
|
|
2362
|
-
[`${ns}/no-redundant-aria-hidden-with-presentation`]: '
|
|
2710
|
+
[`${ns}/no-redundant-aria-hidden-with-presentation`]: 'warn',
|
|
2363
2711
|
[`${ns}/no-aria-owns-on-void`]: 'error',
|
|
2364
2712
|
[`${ns}/no-title-as-label`]: 'error',
|
|
2365
2713
|
[`${ns}/no-tabs-without-structure`]: 'error',
|
|
@@ -2423,3 +2771,20 @@ export function buildPortabilityRules(ns) {
|
|
|
2423
2771
|
[`${ns}/no-scope-on-td`]: 'error',
|
|
2424
2772
|
}
|
|
2425
2773
|
}
|
|
2774
|
+
|
|
2775
|
+
/** Build Vue-specific rules (Transition live region, click key events). */
|
|
2776
|
+
export function buildVueFrameworkRules(ns) {
|
|
2777
|
+
return {
|
|
2778
|
+
[`${ns}/vue-transition-live-region`]: 'warn',
|
|
2779
|
+
[`${ns}/vue-click-key-events`]: 'error',
|
|
2780
|
+
[`${ns}/vue-router-focus-management`]: 'off',
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
2783
|
+
|
|
2784
|
+
/** Build React/Remix-specific JSX rules (fragment hierarchy, etc.). */
|
|
2785
|
+
export function buildReactFrameworkRules(ns) {
|
|
2786
|
+
return {
|
|
2787
|
+
[`${ns}/react-fragment-ruins-aria`]: 'warn',
|
|
2788
|
+
[`${ns}/react-spa-focus-management`]: 'warn',
|
|
2789
|
+
}
|
|
2790
|
+
}
|