@a11yfred/neighbor 1.1.2 → 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 +81 -90
- package/CONTRIBUTING.md +40 -40
- package/README.md +4 -472
- package/RULES-CONTENT.md +240 -81
- package/RULES-CSS.md +41 -19
- package/RULES-MARKUP.md +168 -94
- 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 +374 -3
- 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 -11
package/lib/rules.js
CHANGED
|
@@ -1663,7 +1663,7 @@ export function makeNoNoninteractiveTabindex(h) {
|
|
|
1663
1663
|
const ROLE_TO_ELEMENT = {
|
|
1664
1664
|
button: 'button',
|
|
1665
1665
|
link: 'a',
|
|
1666
|
-
heading: 'h1
|
|
1666
|
+
heading: 'h1-h6',
|
|
1667
1667
|
checkbox: 'input[type=checkbox]',
|
|
1668
1668
|
radio: 'input[type=radio]',
|
|
1669
1669
|
textbox: 'input or textarea',
|
|
@@ -2265,9 +2265,350 @@ function isEffectivelyEmpty(wrapperNode, h) {
|
|
|
2265
2265
|
})
|
|
2266
2266
|
}
|
|
2267
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
|
+
|
|
2268
2603
|
// ─── All rules map ────────────────────────────────────────────────────────────
|
|
2269
2604
|
|
|
2270
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,
|
|
2271
2612
|
'no-aria-label-on-generic': makeNoAriaLabelOnGeneric,
|
|
2272
2613
|
'no-assertive-live-overuse': makeNoAssertiveLiveOveruse,
|
|
2273
2614
|
'warn-role-alert': makeWarnRoleAlert,
|
|
@@ -2330,6 +2671,13 @@ export const RULE_FACTORIES = {
|
|
|
2330
2671
|
'no-dynamic-content-without-live': makeNoDynamicContentWithoutLive,
|
|
2331
2672
|
'form-field-multiple-labels': makeFormFieldMultipleLabels,
|
|
2332
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,
|
|
2333
2681
|
}
|
|
2334
2682
|
|
|
2335
2683
|
/** Build the rules map for a plugin by applying helpers to all factories. */
|
|
@@ -2350,10 +2698,16 @@ export function buildRecommendedRules(ns) {
|
|
|
2350
2698
|
[`${ns}/no-unblocked-aria-disabled`]: 'error',
|
|
2351
2699
|
[`${ns}/no-roles-without-name`]: 'error',
|
|
2352
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',
|
|
2353
2707
|
[`${ns}/no-presentation-on-focusable`]: 'error',
|
|
2354
2708
|
[`${ns}/no-log-with-interactive-children`]: 'error',
|
|
2355
2709
|
[`${ns}/no-aria-hidden-in-link`]: 'error',
|
|
2356
|
-
[`${ns}/no-redundant-aria-hidden-with-presentation`]: '
|
|
2710
|
+
[`${ns}/no-redundant-aria-hidden-with-presentation`]: 'warn',
|
|
2357
2711
|
[`${ns}/no-aria-owns-on-void`]: 'error',
|
|
2358
2712
|
[`${ns}/no-title-as-label`]: 'error',
|
|
2359
2713
|
[`${ns}/no-tabs-without-structure`]: 'error',
|
|
@@ -2392,7 +2746,7 @@ export function buildRecommendedRules(ns) {
|
|
|
2392
2746
|
[`${ns}/no-tab-without-controls`]: 'off',
|
|
2393
2747
|
[`${ns}/no-href-hash`]: 'off',
|
|
2394
2748
|
[`${ns}/warn-role-alert`]: 'off',
|
|
2395
|
-
[`${ns}/prefer-aria-disabled`]: '
|
|
2749
|
+
[`${ns}/prefer-aria-disabled`]: 'off',
|
|
2396
2750
|
[`${ns}/no-target-blank-without-label`]: 'off',
|
|
2397
2751
|
[`${ns}/no-dialog-without-close`]: 'off',
|
|
2398
2752
|
}
|
|
@@ -2417,3 +2771,20 @@ export function buildPortabilityRules(ns) {
|
|
|
2417
2771
|
[`${ns}/no-scope-on-td`]: 'error',
|
|
2418
2772
|
}
|
|
2419
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
|
+
}
|
package/neighbor-content.mjs
CHANGED
|
@@ -57,7 +57,7 @@
|
|
|
57
57
|
* SJSU Writing Ctr sjsu.edu/writingcenter/docs/handouts/Accessible Writing Strategies.pdf
|
|
58
58
|
*/
|
|
59
59
|
|
|
60
|
-
import { CONTENT_RULE_FACTORIES, buildContentRecommendedRules } from '
|
|
60
|
+
import { CONTENT_RULE_FACTORIES, buildContentRecommendedRules } from '@a11yfred/neighbor/lib/content-rules.js'
|
|
61
61
|
|
|
62
62
|
const NS = '@a11yfred/neighbor/content'
|
|
63
63
|
|
|
@@ -23,12 +23,13 @@
|
|
|
23
23
|
* ]
|
|
24
24
|
*/
|
|
25
25
|
|
|
26
|
-
import { h } from '
|
|
27
|
-
import { buildRules, buildRecommendedRules, buildPortabilityRules } from '
|
|
28
|
-
import { buildUlamRulesAngular, buildUlamRecommendedRulesFramework } from '
|
|
26
|
+
import { h } from '@a11yfred/neighbor/lib/helpers-angular.js'
|
|
27
|
+
import { buildRules, buildRecommendedRules, buildPortabilityRules } from '@a11yfred/neighbor/lib/rules.js'
|
|
28
|
+
import { buildUlamRulesAngular, buildUlamRecommendedRulesFramework } from '@a11yfred/neighbor/lib/ulam-rules.js'
|
|
29
|
+
import { buildAngularFrameworkRules, buildAngularHostRecommendedRules } from '@a11yfred/neighbor/lib/framework-rules.js'
|
|
29
30
|
|
|
30
31
|
const NS = '@a11yfred/neighbor'
|
|
31
|
-
const rules = { ...buildRules(h), ...buildUlamRulesAngular() }
|
|
32
|
+
const rules = { ...buildRules(h), ...buildUlamRulesAngular(), ...buildAngularFrameworkRules() }
|
|
32
33
|
const plugin = { meta: { name: `${NS}/angular` }, rules }
|
|
33
34
|
|
|
34
35
|
let angularA11y = null
|
|
@@ -41,14 +42,36 @@ const ANGULAR_A11Y_RULES = [
|
|
|
41
42
|
'no-positive-tabindex', 'role-has-required-aria', 'table-scope', 'valid-aria',
|
|
42
43
|
]
|
|
43
44
|
|
|
45
|
+
const UNLIKELY_ANGULAR_RULES = new Set([
|
|
46
|
+
'no-autofocus', 'no-distracting-elements'
|
|
47
|
+
])
|
|
48
|
+
|
|
44
49
|
function getAngularA11yRules(plugin) {
|
|
45
50
|
const out = {}
|
|
46
51
|
for (const rule of ANGULAR_A11Y_RULES) {
|
|
47
|
-
if (plugin.rules?.[rule])
|
|
52
|
+
if (plugin.rules?.[rule]) {
|
|
53
|
+
out[`@angular-eslint/template/${rule}`] = UNLIKELY_ANGULAR_RULES.has(rule) ? 'off' : 'error'
|
|
54
|
+
}
|
|
48
55
|
}
|
|
49
56
|
return out
|
|
50
57
|
}
|
|
51
58
|
|
|
59
|
+
const angularRecommended = {
|
|
60
|
+
...buildRecommendedRules(NS),
|
|
61
|
+
...buildPortabilityRules(NS),
|
|
62
|
+
...buildUlamRecommendedRulesFramework(NS),
|
|
63
|
+
...buildAngularHostRecommendedRules(NS),
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Omit rules that are already covered by @angular-eslint/template (if installed):
|
|
67
|
+
if (angularA11y) {
|
|
68
|
+
delete angularRecommended[`${NS}/no-heading-no-content`] // covered by @angular-eslint/template/elements-content
|
|
69
|
+
delete angularRecommended[`${NS}/no-anchor-no-content`] // covered by @angular-eslint/template/elements-content
|
|
70
|
+
delete angularRecommended[`${NS}/no-img-redundant-alt`] // covered by @angular-eslint/template/alt-text
|
|
71
|
+
delete angularRecommended[`${NS}/no-scope-on-td`] // covered by @angular-eslint/template/table-scope
|
|
72
|
+
delete angularRecommended[`${NS}/no-invalid-aria-prop-value`] // covered by @angular-eslint/template/valid-aria
|
|
73
|
+
}
|
|
74
|
+
|
|
52
75
|
export default {
|
|
53
76
|
...plugin,
|
|
54
77
|
configs: {
|
|
@@ -59,9 +82,7 @@ export default {
|
|
|
59
82
|
},
|
|
60
83
|
rules: {
|
|
61
84
|
...(angularA11y ? getAngularA11yRules(angularA11y) : {}),
|
|
62
|
-
...
|
|
63
|
-
...buildPortabilityRules(NS),
|
|
64
|
-
...buildUlamRecommendedRulesFramework(NS),
|
|
85
|
+
...angularRecommended,
|
|
65
86
|
},
|
|
66
87
|
},
|
|
67
88
|
},
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @a11yfred/neighbor - ESLint plugin (Lit)
|
|
3
|
+
*
|
|
4
|
+
* Flags Lit-specific accessibility issues like autofocus within html`...` templates.
|
|
5
|
+
*
|
|
6
|
+
* Usage in eslint.config.js:
|
|
7
|
+
* import neighborLit from '@a11yfred/neighbor/lit'
|
|
8
|
+
*
|
|
9
|
+
* export default [
|
|
10
|
+
* {
|
|
11
|
+
* files: ['**/*.ts', '**/*.js'],
|
|
12
|
+
* plugins: { '@a11yfred/neighbor': neighborLit },
|
|
13
|
+
* rules: neighborLit.configs.recommended.rules,
|
|
14
|
+
* },
|
|
15
|
+
* ]
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { buildLitRules, buildLitRecommendedRules } from '@a11yfred/neighbor/lib/framework-rules.js'
|
|
19
|
+
import { buildRecommendedRules, buildPortabilityRules } from '@a11yfred/neighbor/lib/rules.js'
|
|
20
|
+
|
|
21
|
+
const NS = '@a11yfred/neighbor'
|
|
22
|
+
const rules = buildLitRules()
|
|
23
|
+
const plugin = { meta: { name: `${NS}/lit` }, rules }
|
|
24
|
+
|
|
25
|
+
let litA11y = null
|
|
26
|
+
try { litA11y = (await import('eslint-plugin-lit-a11y')).default } catch {}
|
|
27
|
+
|
|
28
|
+
const LIT_A11Y_RULES = [
|
|
29
|
+
'accessible-emoji', 'alt-text', 'anchor-is-valid', 'aria-activedescendant-has-tabindex',
|
|
30
|
+
'aria-attr-valid-value', 'aria-attrs', 'aria-role', 'aria-unsupported-elements',
|
|
31
|
+
'click-events-have-key-events', 'heading-has-content', 'iframe-title',
|
|
32
|
+
'img-redundant-alt', 'mouse-events-have-key-events', 'no-access-key',
|
|
33
|
+
'no-autofocus', 'no-distracting-elements', 'no-redundant-role',
|
|
34
|
+
'role-has-required-aria-props', 'role-supports-aria-props', 'scope', 'tabindex-no-positive',
|
|
35
|
+
'valid-lang'
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const UNLIKELY_LIT_RULES = new Set([
|
|
39
|
+
'accessible-emoji', 'no-autofocus', 'no-distracting-elements', 'no-redundant-role'
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
function getLitA11yRules(plugin) {
|
|
43
|
+
const out = {}
|
|
44
|
+
for (const rule of LIT_A11Y_RULES) {
|
|
45
|
+
if (plugin.rules?.[rule]) {
|
|
46
|
+
out[`lit-a11y/${rule}`] = UNLIKELY_LIT_RULES.has(rule) ? 'off' : 'error'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const litRecommended = {
|
|
53
|
+
...buildRecommendedRules(NS),
|
|
54
|
+
...buildPortabilityRules(NS),
|
|
55
|
+
...buildLitRecommendedRules(NS),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Omit rules that are already covered by lit-a11y (if installed):
|
|
59
|
+
if (litA11y) {
|
|
60
|
+
delete litRecommended[`${NS}/no-heading-no-content`] // covered by lit-a11y/heading-has-content
|
|
61
|
+
delete litRecommended[`${NS}/no-iframe-no-title`] // covered by lit-a11y/iframe-title
|
|
62
|
+
delete litRecommended[`${NS}/no-img-redundant-alt`] // covered by lit-a11y/img-redundant-alt
|
|
63
|
+
delete litRecommended[`${NS}/no-access-key`] // covered by lit-a11y/no-access-key
|
|
64
|
+
delete litRecommended[`${NS}/no-aria-activedescendant-no-tabindex`] // covered by lit-a11y/aria-activedescendant-has-tabindex
|
|
65
|
+
delete litRecommended[`${NS}/no-anchor-no-content`] // covered by lit-a11y/anchor-is-valid
|
|
66
|
+
delete litRecommended[`${NS}/no-invalid-aria-prop-value`] // covered by lit-a11y/aria-attr-valid-value
|
|
67
|
+
delete litRecommended[`${NS}/no-role-supports-aria-props`] // covered by lit-a11y/role-supports-aria-props
|
|
68
|
+
delete litRecommended[`${NS}/no-scope-on-td`] // covered by lit-a11y/scope
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default {
|
|
72
|
+
...plugin,
|
|
73
|
+
configs: {
|
|
74
|
+
recommended: {
|
|
75
|
+
plugins: {
|
|
76
|
+
[NS]: plugin,
|
|
77
|
+
...(litA11y ? { 'lit-a11y': litA11y } : {}),
|
|
78
|
+
},
|
|
79
|
+
rules: {
|
|
80
|
+
...(litA11y ? getLitA11yRules(litA11y) : {}),
|
|
81
|
+
...litRecommended,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @a11yfred/neighbor - ESLint plugin (Remix 3)
|
|
3
|
+
*
|
|
4
|
+
* Flags the same ARIA anti-patterns as neighbor-eslint.mjs, plus specific
|
|
5
|
+
* ulam rules tailored for Remix (e.g., no-use-page-title-in-remix).
|
|
6
|
+
*
|
|
7
|
+
* Usage in eslint.config.js:
|
|
8
|
+
* import neighbor from '@a11yfred/neighbor/remix3'
|
|
9
|
+
*
|
|
10
|
+
* export default [
|
|
11
|
+
* {
|
|
12
|
+
* files: ['**/*.tsx', '**/*.jsx'],
|
|
13
|
+
* plugins: { '@a11yfred/neighbor': neighbor },
|
|
14
|
+
* rules: neighbor.configs.recommended.rules,
|
|
15
|
+
* },
|
|
16
|
+
* ]
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { h } from '@a11yfred/neighbor/lib/helpers-jsx.js'
|
|
20
|
+
import { buildRules, buildRecommendedRules, buildReactFrameworkRules } from '@a11yfred/neighbor/lib/rules.js'
|
|
21
|
+
import { buildUlamRules, buildUlamRecommendedRules } from '@a11yfred/neighbor/lib/ulam-rules.js'
|
|
22
|
+
import { buildRemixRules, buildRemixRecommendedRules } from '@a11yfred/neighbor/lib/framework-rules.js'
|
|
23
|
+
|
|
24
|
+
const NS = '@a11yfred/neighbor'
|
|
25
|
+
const rules = { ...buildRules(h), ...buildUlamRules(), ...buildRemixRules() }
|
|
26
|
+
|
|
27
|
+
const plugin = { meta: { name: `${NS}/remix3` }, rules }
|
|
28
|
+
|
|
29
|
+
let jsxA11y = null
|
|
30
|
+
try { jsxA11y = (await import('eslint-plugin-jsx-a11y')).default } catch {}
|
|
31
|
+
|
|
32
|
+
export default {
|
|
33
|
+
...plugin,
|
|
34
|
+
configs: {
|
|
35
|
+
recommended: {
|
|
36
|
+
plugins: {
|
|
37
|
+
[NS]: plugin,
|
|
38
|
+
...(jsxA11y ? { 'jsx-a11y': jsxA11y } : {}),
|
|
39
|
+
},
|
|
40
|
+
rules: {
|
|
41
|
+
...(jsxA11y ? jsxA11y.configs.recommended.rules : {}),
|
|
42
|
+
...buildRecommendedRules(NS),
|
|
43
|
+
...buildUlamRecommendedRules(NS),
|
|
44
|
+
...buildReactFrameworkRules(NS),
|
|
45
|
+
...buildRemixRecommendedRules(NS),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
package/neighbor-eslint-vue.mjs
CHANGED
|
@@ -18,9 +18,9 @@
|
|
|
18
18
|
* ]
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { h } from '
|
|
22
|
-
import { buildRules, buildRecommendedRules, buildPortabilityRules } from '
|
|
23
|
-
import { buildUlamRulesVue, buildUlamRecommendedRulesFramework } from '
|
|
21
|
+
import { h } from '@a11yfred/neighbor/lib/helpers-vue.js'
|
|
22
|
+
import { buildRules, buildRecommendedRules, buildPortabilityRules, buildVueFrameworkRules } from '@a11yfred/neighbor/lib/rules.js'
|
|
23
|
+
import { buildUlamRulesVue, buildUlamRecommendedRulesFramework } from '@a11yfred/neighbor/lib/ulam-rules.js'
|
|
24
24
|
|
|
25
25
|
const NS = '@a11yfred/neighbor'
|
|
26
26
|
const rules = { ...buildRules(h), ...buildUlamRulesVue() }
|
|
@@ -29,6 +29,24 @@ const plugin = { meta: { name: `${NS}/vue` }, rules }
|
|
|
29
29
|
let vueA11y = null
|
|
30
30
|
try { vueA11y = (await import('eslint-plugin-vuejs-accessibility')).default } catch {}
|
|
31
31
|
|
|
32
|
+
const vueRecommended = {
|
|
33
|
+
...buildRecommendedRules(NS),
|
|
34
|
+
...buildPortabilityRules(NS),
|
|
35
|
+
...buildUlamRecommendedRulesFramework(NS),
|
|
36
|
+
...buildVueFrameworkRules(NS),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Omit rules that are already covered by vuejs-accessibility (if installed):
|
|
40
|
+
if (vueA11y) {
|
|
41
|
+
delete vueRecommended[`${NS}/no-heading-no-content`] // covered by vuejs-accessibility/heading-has-content
|
|
42
|
+
delete vueRecommended[`${NS}/no-iframe-no-title`] // covered by vuejs-accessibility/iframe-has-title
|
|
43
|
+
delete vueRecommended[`${NS}/no-access-key`] // covered by vuejs-accessibility/no-access-key
|
|
44
|
+
delete vueRecommended[`${NS}/no-img-redundant-alt`] // covered by vuejs-accessibility/alt-text
|
|
45
|
+
delete vueRecommended[`${NS}/no-anchor-no-content`] // covered by vuejs-accessibility/anchor-has-content
|
|
46
|
+
delete vueRecommended[`${NS}/no-invalid-aria-prop-value`] // covered by vuejs-accessibility/aria-props
|
|
47
|
+
delete vueRecommended[`${NS}/no-role-supports-aria-props`] // covered by vuejs-accessibility/aria-role
|
|
48
|
+
}
|
|
49
|
+
|
|
32
50
|
export default {
|
|
33
51
|
...plugin,
|
|
34
52
|
configs: {
|
|
@@ -39,9 +57,11 @@ export default {
|
|
|
39
57
|
},
|
|
40
58
|
rules: {
|
|
41
59
|
...(vueA11y ? vueA11y.configs['flat/recommended'].rules : {}),
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
60
|
+
'vuejs-accessibility/accessible-emoji': 'off',
|
|
61
|
+
'vuejs-accessibility/no-autofocus': 'off',
|
|
62
|
+
'vuejs-accessibility/no-distracting-elements': 'off',
|
|
63
|
+
'vuejs-accessibility/no-redundant-roles': 'off',
|
|
64
|
+
...vueRecommended,
|
|
45
65
|
},
|
|
46
66
|
},
|
|
47
67
|
},
|