@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.
@@ -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
- // Native form controls that support HTML disabled per spec - aria-disabled is
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: 'Suggest aria-disabled over the HTML disabled attribute for better AT discoverability' },
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. Consider aria-disabled="true" instead, which keeps the element reachable and lets you explain the reason. Guard the onClick handler when using aria-disabled. (Roselli: Don\'t Disable Form Controls)',
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: 'h1h6',
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`]: 'error',
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
+ }