Skip to content

feat(eslint-plugin): add rule to forbid IDREF ARIA attributes (VIV-2390) #2326

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
May 12, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/docs/content/_data/eslintRules.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,9 @@
{
"name": "no-current-value-attribute",
"markdown": "./libs/eslint-plugin/src/rules/no-current-value-attribute.md"
},
{
"name": "no-idref-aria-attribute",
"markdown": "./libs/eslint-plugin/src/rules/no-idref-aria-attribute.md"
}
]
15 changes: 15 additions & 0 deletions libs/eslint-plugin/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ describe('eslint-plugin', () => {
"@vonage/vivid/no-anchor-attribute": "error",
"@vonage/vivid/no-current-value-attribute": "error",
"@vonage/vivid/no-deprecated-apis": "error",
"@vonage/vivid/no-idref-aria-attribute": "error",
"@vonage/vivid/no-inaccessible-events": "error",
"@vonage/vivid/no-slot-attribute": "error",
"@vonage/vivid/no-value-attribute": "error",
Expand All @@ -30,6 +31,7 @@ describe('eslint-plugin', () => {
"@vonage/vivid/no-anchor-attribute": "error",
"@vonage/vivid/no-current-value-attribute": "error",
"@vonage/vivid/no-deprecated-apis": "error",
"@vonage/vivid/no-idref-aria-attribute": "error",
"@vonage/vivid/no-inaccessible-events": "error",
"@vonage/vivid/no-slot-attribute": "error",
"@vonage/vivid/no-value-attribute": "error",
Expand Down Expand Up @@ -80,6 +82,19 @@ describe('eslint-plugin', () => {
"type": "problem",
},
},
"no-idref-aria-attribute": {
"create": [Function],
"meta": {
"docs": {
"description": "Do not use IDREF ARIA attributes on components.",
},
"messages": {
"noIdrefAriaAttribute": "IDREF ARIA attributes (like {{attribute}}) should not be used on components that delegate ARIA attributes, as they will not work correctly with shadow DOM.",
},
"schema": [],
"type": "problem",
},
},
"no-inaccessible-events": {
"create": [Function],
"meta": {
Expand Down
9 changes: 6 additions & 3 deletions libs/eslint-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { ESLint } from 'eslint';
import { noDeprecatedAPIs } from './rules/no-deprecated-apis';
import { accessibleNames } from './rules/accessible-names';
import { noInaccessibleEvents } from './rules/no-inaccessible-events';
import { noAnchorAttribute } from './rules/no-anchor-attribute';
import { noCurrentValueAttribute } from './rules/no-current-value-attribute';
import { noDeprecatedAPIs } from './rules/no-deprecated-apis';
import { noIdrefAriaAttribute } from './rules/no-idref-aria-attribute';
import { noInaccessibleEvents } from './rules/no-inaccessible-events';
import { noSlotAttribute } from './rules/no-slot-attribute';
import { noValueAttribute } from './rules/no-value-attribute';
import { noCurrentValueAttribute } from './rules/no-current-value-attribute';

const rules = {
'@vonage/vivid/no-deprecated-apis': 'error',
Expand All @@ -15,6 +16,7 @@ const rules = {
'@vonage/vivid/no-slot-attribute': 'error',
'@vonage/vivid/no-value-attribute': 'error',
'@vonage/vivid/no-current-value-attribute': 'error',
'@vonage/vivid/no-idref-aria-attribute': 'error',
} as const;

const eslintPluginVivid: ESLint.Plugin = {
Expand All @@ -32,6 +34,7 @@ const eslintPluginVivid: ESLint.Plugin = {
'no-slot-attribute': noSlotAttribute,
'no-value-attribute': noValueAttribute,
'no-current-value-attribute': noCurrentValueAttribute,
'no-idref-aria-attribute': noIdrefAriaAttribute,
},
};

Expand Down
29 changes: 29 additions & 0 deletions libs/eslint-plugin/src/rules/no-idref-aria-attribute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
This rule prevents the use of IDREF ARIA attributes on components. These attributes will not work correctly with shadow DOM. Why? When these components are used with shadow DOM, IDREF ARIA attributes will not work correctly because they cannot reference elements across shadow DOM boundaries.

The following ARIA attributes are IDREF attributes and should not be used on components.

- `aria-activedescendant`
- `aria-controls`
- `aria-describedby`
- `aria-details`
- `aria-errormessage`
- `aria-flowto`
- `aria-labelledby`
- `aria-owns`

#### Example

```html
<!-- ❌ BAD -->
<VButton aria-labelledby="button-label">Click me</VButton>
<div id="button-label">This is a button</div>
<VTextField aria-describedby="field-description"></VTextField>
<div id="field-description">Enter your name</div>
<VMenu aria-controls="menu-trigger"></VMenu>
<button id="menu-trigger">Open menu</button>

<!-- ✅ GOOD -->
<VButton aria-label="This is a button">Click me</VButton>
<VTextField aria-label="Name" aria-description="Enter your name"></VTextField>
<VMenu aria-label="Menu"></VMenu>
```
143 changes: 143 additions & 0 deletions libs/eslint-plugin/src/rules/no-idref-aria-attribute.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { RuleTester } from 'eslint';
import { noIdrefAriaAttribute } from './no-idref-aria-attribute';
import { convertAnnotatedSourceToFailureCase } from '../utils/testing';

const ruleTester = new RuleTester({
parser: require.resolve('vue-eslint-parser'),
parserOptions: {
sourceType: 'module',
},
});

ruleTester.run('no-idref-aria-attribute', noIdrefAriaAttribute, {
valid: [
{
code: '<template><VBreadcrumb aria-label="breadcrumb"></VBreadcrumb></template>',
},
{
code: '<template><VButton aria-label="button"></VButton></template>',
},
{
code: '<template><VNav aria-label="navigation"></VNav></template>',
},
{
code: '<template><VTagGroup aria-label="tags"></VTagGroup></template>',
},
{
code: '<template><VTag aria-label="tag"></VTag></template>',
},
{
code: '<template><VActionGroup aria-label="actions"></VActionGroup></template>',
},
{
code: '<template><VHeader aria-label="header"></VHeader></template>',
},
{
code: '<template><VSwitch aria-label="switch"></VSwitch></template>',
},
{
code: '<template><VDivider aria-label="divider"></VDivider></template>',
},
{
code: '<template><VTextArea aria-label="textarea"></VTextArea></template>',
},
{
code: '<template><VCheckbox aria-label="checkbox"></VCheckbox></template>',
},
{
code: '<template><VSearchableSelect aria-label="select"></VSearchableSelect></template>',
},
{
code: '<template><VFilePicker aria-label="file picker"></VFilePicker></template>',
},
{
code: '<template><VCalendarEvent aria-label="event"></VCalendarEvent></template>',
},
{
code: '<template><VProgress aria-label="progress"></VProgress></template>',
},
{
code: '<template><VProgressRing aria-label="progress"></VProgressRing></template>',
},
{
code: '<template><VSelectableBox aria-label="box"></VSelectableBox></template>',
},
{
code: '<template><VBanner aria-label="banner"></VBanner></template>',
},
{
code: '<template><VMenu aria-label="menu"></VMenu></template>',
},
{
code: '<template><VTextField aria-label="text field"></VTextField></template>',
},
{
code: '<template><VDialog aria-label="dialog"></VDialog></template>',
},
{
code: '<template><VSlider aria-label="slider"></VSlider></template>',
},
{
code: '<template><VTextAnchor aria-label="anchor"></VTextAnchor></template>',
},
{
code: '<template><VSplitButton aria-label="split button"></VSplitButton></template>',
},
{
code: '<template><VNumberField aria-label="number field"></VNumberField></template>',
},
{
code: '<template><VNavDisclosure aria-label="disclosure"></VNavDisclosure></template>',
},
],
invalid: [
convertAnnotatedSourceToFailureCase({
annotatedSource: `
<template><VMenu aria-controls="id"></VMenu></template>
~~~~~~~~~~~~~
`,
message:
'IDREF ARIA attributes (like aria-controls) should not be used on components that delegate ARIA attributes, as they will not work correctly with shadow DOM.',
}),
convertAnnotatedSourceToFailureCase({
annotatedSource: `
<template><VButton aria-labelledby="id"></VButton></template>
~~~~~~~~~~~~~~~
`,
message:
'IDREF ARIA attributes (like aria-labelledby) should not be used on components that delegate ARIA attributes, as they will not work correctly with shadow DOM.',
}),
convertAnnotatedSourceToFailureCase({
annotatedSource: `
<template><VTextField aria-owns="id"></VTextField></template>
~~~~~~~~~
`,
message:
'IDREF ARIA attributes (like aria-owns) should not be used on components that delegate ARIA attributes, as they will not work correctly with shadow DOM.',
}),
convertAnnotatedSourceToFailureCase({
annotatedSource: `
<template><VDialog aria-details="id"></VDialog></template>
~~~~~~~~~~~~
`,
message:
'IDREF ARIA attributes (like aria-details) should not be used on components that delegate ARIA attributes, as they will not work correctly with shadow DOM.',
}),
convertAnnotatedSourceToFailureCase({
annotatedSource: `
<template><VMenu aria-errormessage="id"></VMenu></template>
~~~~~~~~~~~~~~~~~
`,
message:
'IDREF ARIA attributes (like aria-errormessage) should not be used on components that delegate ARIA attributes, as they will not work correctly with shadow DOM.',
}),
convertAnnotatedSourceToFailureCase({
annotatedSource: `
<template><VButton aria-flowto="id"></VButton></template>
~~~~~~~~~~~
`,
message:
'IDREF ARIA attributes (like aria-flowto) should not be used on components that delegate ARIA attributes, as they will not work correctly with shadow DOM.',
}),
],
});
80 changes: 80 additions & 0 deletions libs/eslint-plugin/src/rules/no-idref-aria-attribute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { Rule } from 'eslint';
import * as utils from 'eslint-plugin-vue/lib/utils/index.js';
import { getAttributes } from '../utils/attributes';
import { ComponentMetadata } from '../utils/ComponentMetadata';
import { normalizeTag } from '../utils/components';

const IDREF_ARIA_ATTRIBUTES = [
'aria-activedescendant',
'aria-controls',
'aria-describedby',
'aria-details',
'aria-errormessage',
'aria-flowto',
'aria-labelledby',
'aria-owns',
];

const components = new ComponentMetadata<null>();

// Add all components that use DelegatesAria
components.add('VBreadcrumb', null);
components.add('VBreadcrumbItem', null);
components.add('VButton', null);
components.add('VNav', null);
components.add('VTagGroup', null);
components.add('VTag', null);
components.add('VActionGroup', null);
components.add('VHeader', null);
components.add('VSwitch', null);
components.add('VDivider', null);
components.add('VTextArea', null);
components.add('VCheckbox', null);
components.add('VSearchableSelect', null);
components.add('VFilePicker', null);
components.add('VCalendarEvent', null);
components.add('VProgress', null);
components.add('VProgressRing', null);
components.add('VSelectableBox', null);
components.add('VBanner', null);
components.add('VMenu', null);
components.add('VTextField', null);
components.add('VDialog', null);
components.add('VSlider', null);
components.add('VTextAnchor', null);
components.add('VSplitButton', null);
components.add('VNumberField', null);
components.add('VNavDisclosure', null);

export const noIdrefAriaAttribute: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'Do not use IDREF ARIA attributes on components.',
},
messages: {
noIdrefAriaAttribute:
'IDREF ARIA attributes (like {{attribute}}) should not be used on components that delegate ARIA attributes, as they will not work correctly with shadow DOM.',
},
schema: [],
},
create(context) {
return utils.defineTemplateBodyVisitor(context, {
VElement(node) {
const tagName = normalizeTag(node.name);
components.forTag(tagName, () => {
const attrs = getAttributes(node.startTag);
for (const attr of attrs) {
if (IDREF_ARIA_ATTRIBUTES.includes(attr.node.rawName)) {
context.report({
loc: attr.node.loc,
messageId: 'noIdrefAriaAttribute',
data: { attribute: attr.node.rawName },
});
}
}
});
},
});
},
};
Loading