@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/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: 'h1h6',
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`]: 'error',
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`]: 'warn',
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
+ }
@@ -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 './lib/content-rules.js'
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 './lib/helpers-angular.js'
27
- import { buildRules, buildRecommendedRules, buildPortabilityRules } from './lib/rules.js'
28
- import { buildUlamRulesAngular, buildUlamRecommendedRulesFramework } from './lib/ulam-rules.js'
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]) out[`@angular-eslint/template/${rule}`] = 'error'
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
- ...buildRecommendedRules(NS),
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
+ }
@@ -18,9 +18,9 @@
18
18
  * ]
19
19
  */
20
20
 
21
- import { h } from './lib/helpers-vue.js'
22
- import { buildRules, buildRecommendedRules, buildPortabilityRules } from './lib/rules.js'
23
- import { buildUlamRulesVue, buildUlamRecommendedRulesFramework } from './lib/ulam-rules.js'
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
- ...buildRecommendedRules(NS),
43
- ...buildPortabilityRules(NS),
44
- ...buildUlamRecommendedRulesFramework(NS),
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
  },