Skip to content
Snippets Groups Projects
Commit 06c44cec authored by David Sveningsson's avatar David Sveningsson
Browse files

feat(rules): new rule `prefer-native-element`

parent f5ffb643
No related branches found
No related tags found
1 merge request!357feat(rules): new rule `prefer-native-element`
Pipeline #111546678 passed
......@@ -27,6 +27,13 @@ footer {
padding: 3rem 0;
}
table {
th,
td {
padding: $table-cell-padding;
}
}
.rules-table {
.set {
width: 5%;
......
......@@ -35,6 +35,7 @@
</div>
<div class="collapse navbar-collapse" id="navbar">
<ul class="nav navbar-nav">
<!-- [html-validate-disable-block prefer-native-element: bootstrap styles button way different and this is their recommended markup] -->
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">User guide <span class="caret"></span></a>
<ul class="dropdown-menu">
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`docs/rules/prefer-native-element.md inline validation: correct 1`] = `Array []`;
exports[`docs/rules/prefer-native-element.md inline validation: incorrect 1`] = `
Array [
Object {
"errorCount": 1,
"filePath": "inline",
"messages": Array [
Object {
"column": 6,
"context": Object {
"replacement": "main",
"role": "main",
},
"line": 1,
"message": "Prefer to use the native <main> element",
"offset": 5,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
],
"source": "<div role=\\"main\\">
<p>Lorem ipsum</p>
</div>",
"warningCount": 0,
},
]
`;
import HtmlValidate from "../../../src/htmlvalidate";
const markup: { [key: string]: string } = {};
markup["incorrect"] = `<div role="main">
<p>Lorem ipsum</p>
</div>`;
markup["correct"] = `<main>
<p>Lorem ipsum</p>
</main>`;
describe("docs/rules/prefer-native-element.md", () => {
it("inline validation: incorrect", () => {
const htmlvalidate = new HtmlValidate({"rules":{"prefer-native-element":"error"}});
const report = htmlvalidate.validateString(markup["incorrect"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: correct", () => {
const htmlvalidate = new HtmlValidate({"rules":{"prefer-native-element":"error"}});
const report = htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
});
---
docType: rule
name: prefer-native-element
category: a17y
summary: Prefer to use native HTML element over roles
---
# Prefer to use native HTML element over roles (`prefer-native-element`)
While WAI-ARIA describes many [roles][wai-aria-roles] which can provide semantic information about what the element represents.
Support for roles is varying and since HTML5 has many native equivalent elements it is better to use the native when possible.
[wai-aria-roles]: https://www.w3.org/TR/wai-aria-1.1/#role_definitions
Table of equivalent elements:
<!-- [html-validate-disable-block element-required-attributes: marked does not generate tables with scope attribute] -->
| Role | Element |
| ------------- | ---------------------- |
| article | article |
| banner | header |
| button | button |
| cell | td |
| checkbox | input |
| complementary | aside |
| contentinfo | footer |
| figure | figure |
| form | form |
| heading | h1, h2, h3, h4, h5, h6 |
| input | input |
| link | a |
| list | ul, ol |
| listbox | select |
| listitem | li |
| main | main |
| navigation | nav |
| progressbar | progress |
| radio | input |
| region | section |
| table | table |
| textbox | textarea |
## Rule details
Examples of **incorrect** code for this rule:
<validate name="incorrect" rules="prefer-native-element">
<div role="main">
<p>Lorem ipsum</p>
</div>
</validate>
Examples of **correct** code for this rule:
<validate name="correct" rules="prefer-native-element">
<main>
<p>Lorem ipsum</p>
</main>
</validate>
## Options
This rule takes an optional object:
```javascript
{
"mapping": { .. },
"include": [],
"exclude": [],
}
```
### `mapping`
Object with roles to map to native elements.
This can be used to provide a custom map with roles and their replacement.
### `include`
If set only roles listed in this array generates errors.
### `exclude`
If set roles listed in this array is ignored.
......@@ -30,6 +30,7 @@ module.exports = {
"no-raw-characters": "error",
"no-trailing-whitespace": "error",
"prefer-button": "error",
"prefer-native-element": "error",
"prefer-tbody": "error",
"svg-focusable": "error",
"unrecognized-char-ref": "error",
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule prefer-native-element default config smoketest 1`] = `
Array [
Object {
"errorCount": 22,
"filePath": "test-files/rules/prefer-native-element.html",
"messages": Array [
Object {
"column": 6,
"context": Object {
"replacement": "article",
"role": "article",
},
"line": 3,
"message": "Prefer to use the native <article> element",
"offset": 33,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 14,
},
Object {
"column": 6,
"context": Object {
"replacement": "header",
"role": "banner",
},
"line": 4,
"message": "Prefer to use the native <header> element",
"offset": 60,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 13,
},
Object {
"column": 6,
"context": Object {
"replacement": "button",
"role": "button",
},
"line": 5,
"message": "Prefer to use the native <button> element",
"offset": 86,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 13,
},
Object {
"column": 6,
"context": Object {
"replacement": "td",
"role": "cell",
},
"line": 6,
"message": "Prefer to use the native <td> element",
"offset": 112,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
Object {
"column": 6,
"context": Object {
"replacement": "input",
"role": "checkbox",
},
"line": 7,
"message": "Prefer to use the native <input> element",
"offset": 136,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 15,
},
Object {
"column": 6,
"context": Object {
"replacement": "aside",
"role": "complementary",
},
"line": 8,
"message": "Prefer to use the native <aside> element",
"offset": 164,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 20,
},
Object {
"column": 6,
"context": Object {
"replacement": "footer",
"role": "contentinfo",
},
"line": 9,
"message": "Prefer to use the native <footer> element",
"offset": 197,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 18,
},
Object {
"column": 6,
"context": Object {
"replacement": "figure",
"role": "figure",
},
"line": 10,
"message": "Prefer to use the native <figure> element",
"offset": 228,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 13,
},
Object {
"column": 6,
"context": Object {
"replacement": "form",
"role": "form",
},
"line": 11,
"message": "Prefer to use the native <form> element",
"offset": 254,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
Object {
"column": 6,
"context": Object {
"replacement": "hN",
"role": "heading",
},
"line": 12,
"message": "Prefer to use the native <hN> element",
"offset": 278,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 14,
},
Object {
"column": 6,
"context": Object {
"replacement": "input",
"role": "input",
},
"line": 13,
"message": "Prefer to use the native <input> element",
"offset": 305,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 12,
},
Object {
"column": 6,
"context": Object {
"replacement": "a",
"role": "link",
},
"line": 14,
"message": "Prefer to use the native <a> element",
"offset": 330,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
Object {
"column": 6,
"context": Object {
"replacement": "ul",
"role": "list",
},
"line": 15,
"message": "Prefer to use the native <ul> element",
"offset": 354,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
Object {
"column": 6,
"context": Object {
"replacement": "select",
"role": "listbox",
},
"line": 16,
"message": "Prefer to use the native <select> element",
"offset": 378,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 14,
},
Object {
"column": 6,
"context": Object {
"replacement": "li",
"role": "listitem",
},
"line": 17,
"message": "Prefer to use the native <li> element",
"offset": 405,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 15,
},
Object {
"column": 6,
"context": Object {
"replacement": "main",
"role": "main",
},
"line": 18,
"message": "Prefer to use the native <main> element",
"offset": 433,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 11,
},
Object {
"column": 6,
"context": Object {
"replacement": "nav",
"role": "navigation",
},
"line": 19,
"message": "Prefer to use the native <nav> element",
"offset": 457,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 17,
},
Object {
"column": 6,
"context": Object {
"replacement": "progress",
"role": "progressbar",
},
"line": 20,
"message": "Prefer to use the native <progress> element",
"offset": 487,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 18,
},
Object {
"column": 6,
"context": Object {
"replacement": "input",
"role": "radio",
},
"line": 21,
"message": "Prefer to use the native <input> element",
"offset": 518,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 12,
},
Object {
"column": 6,
"context": Object {
"replacement": "section",
"role": "region",
},
"line": 22,
"message": "Prefer to use the native <section> element",
"offset": 543,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 13,
},
Object {
"column": 6,
"context": Object {
"replacement": "table",
"role": "table",
},
"line": 23,
"message": "Prefer to use the native <table> element",
"offset": 569,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 12,
},
Object {
"column": 6,
"context": Object {
"replacement": "textarea",
"role": "textbox",
},
"line": 24,
"message": "Prefer to use the native <textarea> element",
"offset": 594,
"ruleId": "prefer-native-element",
"severity": 2,
"size": 14,
},
],
"source": "<div role=\\"unknown\\"></div>
<div role=\\"article\\"></div>
<div role=\\"banner\\"></div>
<div role=\\"button\\"></div>
<div role=\\"cell\\"></div>
<div role=\\"checkbox\\"></div>
<div role=\\"complementary\\"></div>
<div role=\\"contentinfo\\"></div>
<div role=\\"figure\\"></div>
<div role=\\"form\\"></div>
<div role=\\"heading\\"></div>
<div role=\\"input\\"></div>
<div role=\\"link\\"></div>
<div role=\\"list\\"></div>
<div role=\\"listbox\\"></div>
<div role=\\"listitem\\"></div>
<div role=\\"main\\"></div>
<div role=\\"navigation\\"></div>
<div role=\\"progressbar\\"></div>
<div role=\\"radio\\"></div>
<div role=\\"region\\"></div>
<div role=\\"table\\"></div>
<div role=\\"textbox\\"></div>
",
"warningCount": 0,
},
]
`;
exports[`rule prefer-native-element should contain contextual documentation 1`] = `
Object {
"description": "Instead of using the WAI-ARIA role \\"the-role\\" prefer to use the native <the-replacement> element.",
"url": "https://html-validate.org/rules/prefer-native-element.html",
}
`;
exports[`rule prefer-native-element should contain documentation 1`] = `
Object {
"description": "Instead of using WAI-ARIA roles prefer to use the native HTML elements.",
"url": "https://html-validate.org/rules/prefer-native-element.html",
}
`;
import HtmlValidate from "../htmlvalidate";
import "../matchers";
import { processAttribute } from "../transform/mocks/attribute";
describe("rule prefer-native-element", () => {
describe("default config", () => {
let htmlvalidate: HtmlValidate;
beforeAll(() => {
htmlvalidate = new HtmlValidate({
rules: { "prefer-native-element": "error" },
});
});
it("should not report error when using role without native equivalent element", () => {
const report = htmlvalidate.validateString('<div role="unknown"></div>');
expect(report).toBeValid();
});
it("should not report error when role is boolean", () => {
const report = htmlvalidate.validateString("<div role></div>");
expect(report).toBeValid();
});
it("should not report error for dynamic attributes", () => {
const report = htmlvalidate.validateString(
'<input dynamic-role="main">',
null,
{
processAttribute,
}
);
expect(report).toBeValid();
});
it("should report error when using role with native equivalent element", () => {
const report = htmlvalidate.validateString('<div role="main"></div>');
expect(report).toBeInvalid();
expect(report).toHaveError(
"prefer-native-element",
"Prefer to use the native <main> element"
);
});
it("should handle unquoted role", () => {
const report = htmlvalidate.validateString("<div role=main></div>");
expect(report).toBeInvalid();
expect(report).toHaveError(
"prefer-native-element",
"Prefer to use the native <main> element"
);
});
it("smoketest", () => {
const report = htmlvalidate.validateFile(
"test-files/rules/prefer-native-element.html"
);
expect(report.results).toMatchSnapshot();
});
});
it("should not report error when role is excluded", () => {
const htmlvalidate = new HtmlValidate({
rules: { "prefer-native-element": ["error", { exclude: ["main"] }] },
});
const valid = htmlvalidate.validateString('<div role="main"></div>');
const invalid = htmlvalidate.validateString('<div role="article"></div>');
expect(valid).toBeValid();
expect(invalid).toBeInvalid();
});
it("should report error only for included roles", () => {
const htmlvalidate = new HtmlValidate({
rules: { "prefer-native-element": ["error", { include: ["main"] }] },
});
const valid = htmlvalidate.validateString('<div role="article"></div>');
const invalid = htmlvalidate.validateString('<div role="main"></div>');
expect(valid).toBeValid();
expect(invalid).toBeInvalid();
});
it("should contain documentation", () => {
const htmlvalidate = new HtmlValidate({
rules: { "prefer-native-element": "error" },
});
expect(
htmlvalidate.getRuleDocumentation("prefer-native-element")
).toMatchSnapshot();
});
it("should contain contextual documentation", () => {
const htmlvalidate = new HtmlValidate({
rules: { "prefer-native-element": "error" },
});
const context = {
role: "the-role",
replacement: "the-replacement",
};
expect(
htmlvalidate.getRuleDocumentation("prefer-native-element", null, context)
).toMatchSnapshot();
});
});
import { Location } from "../context";
import { DynamicValue } from "../dom";
import { AttributeEvent } from "../event";
import { Rule, RuleDocumentation, ruleDocumentationUrl } from "../rule";
interface RuleContext {
role: string;
replacement: string;
}
interface RuleOptions {
mapping: Record<string, string>;
include: string[] | null;
exclude: string[] | null;
}
const defaults: RuleOptions = {
mapping: {
article: "article",
banner: "header",
button: "button",
cell: "td",
checkbox: "input",
complementary: "aside",
contentinfo: "footer",
figure: "figure",
form: "form",
heading: "hN",
input: "input",
link: "a",
list: "ul",
listbox: "select",
listitem: "li",
main: "main",
navigation: "nav",
progressbar: "progress",
radio: "input",
region: "section",
table: "table",
textbox: "textarea",
},
include: null,
exclude: null,
};
class PreferNativeElement extends Rule<RuleContext, RuleOptions> {
public constructor(options: RuleOptions) {
super(Object.assign({}, defaults, options));
}
public documentation(context: RuleContext): RuleDocumentation {
const doc: RuleDocumentation = {
description: `Instead of using WAI-ARIA roles prefer to use the native HTML elements.`,
url: ruleDocumentationUrl(__filename),
};
if (context) {
doc.description = `Instead of using the WAI-ARIA role "${context.role}" prefer to use the native <${context.replacement}> element.`;
}
return doc;
}
public setup(): void {
const { mapping } = this.options;
this.on("attr", (event: AttributeEvent) => {
/* ignore non-role attributes */
if (event.key.toLowerCase() !== "role") {
return;
}
/* ignore missing and dynamic values */
if (!event.value || event.value instanceof DynamicValue) {
return;
}
/* ignore roles configured to be ignored */
const role = event.value.toLowerCase();
if (this.isIgnored(role)) {
return;
}
/* report error */
const replacement = mapping[role];
const context: RuleContext = { role, replacement };
const location = this.getLocation(event);
this.report(
event.target,
`Prefer to use the native <${replacement}> element`,
location,
context
);
});
}
private isIgnored(role: string): boolean {
const { mapping, include, exclude } = this.options;
/* ignore roles not mapped to native elements */
const replacement = mapping[role];
if (!replacement) {
return true;
}
/* ignore roles not present in "include" */
if (include && !include.includes(role)) {
return true;
}
/* ignore roles present in "excludes" */
if (exclude && exclude.includes(role)) {
return true;
}
return false;
}
private getLocation(event: AttributeEvent): Location {
const begin = event.location;
const end = event.valueLocation;
const quote = event.quote ? 1 : 0;
const size = end.offset + end.size - begin.offset + quote;
return {
filename: begin.filename,
line: begin.line,
column: begin.column,
offset: begin.offset,
size,
};
}
}
module.exports = PreferNativeElement;
<div role="unknown"></div>
<div role="article"></div>
<div role="banner"></div>
<div role="button"></div>
<div role="cell"></div>
<div role="checkbox"></div>
<div role="complementary"></div>
<div role="contentinfo"></div>
<div role="figure"></div>
<div role="form"></div>
<div role="heading"></div>
<div role="input"></div>
<div role="link"></div>
<div role="list"></div>
<div role="listbox"></div>
<div role="listitem"></div>
<div role="main"></div>
<div role="navigation"></div>
<div role="progressbar"></div>
<div role="radio"></div>
<div role="region"></div>
<div role="table"></div>
<div role="textbox"></div>
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment