Commit 2fef3950 authored by David Sveningsson's avatar David Sveningsson
Browse files

feat(rules): new rule `text-content`

fixes #101
parent 3052f81e
Pipeline #243346064 passed with stages
in 10 minutes and 34 seconds
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`docs/rules/text-content.md inline validation: correct 1`] = `Array []`;
exports[`docs/rules/text-content.md inline validation: incorrect 1`] = `
Array [
Object {
"errorCount": 1,
"filePath": "inline",
"messages": Array [
Object {
"column": 2,
"context": Object {
"tagName": "button",
"textContent": "accessible",
},
"line": 1,
"message": "<button> must have accessible text",
"offset": 1,
"ruleId": "text-content",
"selector": "button",
"severity": 2,
"size": 6,
},
],
"source": "<button type=\\"button\\"></button>",
"warningCount": 0,
},
]
`;
import HtmlValidate from "../../../src/htmlvalidate";
const markup: { [key: string]: string } = {};
markup["incorrect"] = `<button type="button"></button>`;
markup["correct"] = `<!-- regular static text -->
<button type="button">Add item</button>
<!-- text from aria-label -->
<button type="button" aria-label="Add item">
<i class="fa fa-plus" aria-hidden="true"></i>
</button>`;
describe("docs/rules/text-content.md", () => {
it("inline validation: incorrect", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"text-content":"error"}});
const report = htmlvalidate.validateString(markup["incorrect"]);
expect(report.results).toMatchSnapshot();
});
it("inline validation: correct", () => {
expect.assertions(1);
const htmlvalidate = new HtmlValidate({"rules":{"text-content":"error"}});
const report = htmlvalidate.validateString(markup["correct"]);
expect(report.results).toMatchSnapshot();
});
});
---
docType: rule
name: text-content
category: a17y
summary: Require elements to have valid text content
---
# Require elements to have valid text (`text-content`)
Requires presence or absence of textual content on an element (or one of its children).
Whitespace is ignored.
It comes in three variants:
- Text must be absent.
- Text must be present.
- Text must be accessible (regular text or aria attributes).
Bundled HTML5 elements only specify accessible text but custom elements can specify others.
By default this rules validates:
- `<button>`
## Rule details
Examples of **incorrect** code for this rule:
<validate name="incorrect" rules="text-content">
<button type="button"></button>
</validate>
Examples of **correct** code for this rule:
<validate name="correct" rules="text-content">
<!-- regular static text -->
<button type="button">Add item</button>
<!-- text from aria-label -->
<button type="button" aria-label="Add item">
<i class="fa fa-plus" aria-hidden="true"></i>
</button>
</validate>
......@@ -51,6 +51,7 @@ export interface MetaElement {
permittedOrder?: PermittedOrder;
requiredAncestors?: string[];
requiredContent?: string[];
textContent?: "none" | "default" | "required" | "accessible";
/* inheritance */
inherit?: string;
......@@ -427,6 +428,20 @@ descendant of the element.
This is used by [element-required-content](/rules/element-required-content.html)
rule.
### `textContent`
Enforces presence or absence of text in an element.
If unset it defaults to `default`.
Must be set to one of the following values:
- `none` - the element cannot contain text (whitespace ignored).
- `default` - the element can optionally have text.
- `required` - the element must have non-whitespace text present.
- `accessible` - the element must have accessible text, either regular text or using aria attributes such as `aria-label`.
This is used by [text-content](/rules/text-content.html) rule.
## Global element
The special `*` element can be used to assign global metadata applying to all
......
......@@ -486,9 +486,9 @@ Array [
Object {
"column": 3,
"context": undefined,
"line": 22,
"line": 23,
"message": "Element <audio> is not permitted as descendant of <button>",
"offset": 456,
"offset": 464,
"ruleId": "element-permitted-content",
"selector": "button > audio",
"severity": 2,
......@@ -507,9 +507,9 @@ Array [
"element": "audio",
"value": "foobar",
},
"line": 26,
"line": 27,
"message": "Attribute \\"preload\\" has invalid value \\"foobar\\"",
"offset": 557,
"offset": 565,
"ruleId": "attribute-allowed-values",
"selector": "audio:nth-child(6)",
"severity": 2,
......@@ -537,6 +537,7 @@ Array [
<!-- should be interactive if missing controls attribute (and thus not allowed as content in button) -->
<button type=\\"button\\">
Audio:
<audio controls=\\"foo\\"></audio>
</button>
......@@ -898,7 +899,7 @@ exports[`HTML elements <br> valid markup 1`] = `Array []`;
exports[`HTML elements <button> invalid markup 1`] = `
Array [
Object {
"errorCount": 6,
"errorCount": 8,
"filePath": "test-files/elements/button-invalid.html",
"messages": Array [
Object {
......@@ -973,12 +974,40 @@ Array [
},
"line": 15,
"message": "<button> is missing required \\"type\\" attribute",
"offset": 453,
"offset": 456,
"ruleId": "element-required-attributes",
"selector": "button:nth-child(6)",
"severity": 2,
"size": 6,
},
Object {
"column": 2,
"context": Object {
"tagName": "button",
"textContent": "accessible",
},
"line": 18,
"message": "<button> must have accessible text",
"offset": 511,
"ruleId": "text-content",
"selector": "button:nth-child(7)",
"severity": 2,
"size": 6,
},
Object {
"column": 2,
"context": Object {
"tagName": "button",
"textContent": "accessible",
},
"line": 19,
"message": "<button> must have accessible text",
"offset": 543,
"ruleId": "text-content",
"selector": "button:nth-child(8)",
"severity": 2,
"size": 6,
},
],
"source": "<!-- should not allow flow as content -->
<button type=\\"button\\"><div>foo</div></button>
......@@ -991,10 +1020,14 @@ Array [
<button type=\\"button\\"><span><button type=\\"button\\">foo</button></span></button>
<!-- disallowed type variants -->
<button type=\\"foobar\\"></button>
<button type=\\"foobar\\">foo</button>
<!-- missing type -->
<button></button>
<button>foo</button>
<!-- missing accessible text -->
<button type=\\"button\\"></button>
<button type=\\"button\\"><span aria-hidden=\\"true\\">foo</span></button>
",
"warningCount": 0,
},
......@@ -3043,7 +3076,7 @@ Array [
<!-- should be interactive if usemap is set -->
<button type=\\"button\\">
<img src=\\"foo.png\\" usemap>
<img src=\\"foo.png\\" usemap aria-label=\\"Picture of a foo\\">
</button>
",
"warningCount": 0,
......@@ -3283,9 +3316,9 @@ Array [
Object {
"column": 3,
"context": undefined,
"line": 21,
"line": 22,
"message": "Element <input> is not permitted as descendant of <button>",
"offset": 594,
"offset": 599,
"ruleId": "element-permitted-content",
"selector": "button > input",
"severity": 2,
......@@ -3294,9 +3327,9 @@ Array [
Object {
"column": 20,
"context": undefined,
"line": 25,
"line": 26,
"message": "image used as submit button must have alt text",
"offset": 683,
"offset": 688,
"ruleId": "wcag/h36",
"selector": "input:nth-child(14)",
"severity": 2,
......@@ -3323,6 +3356,7 @@ Array [
<!-- should be interactive when type isn't hidden -->
<button type=\\"button\\">
foo
<input type=\\"text\\">
</button>
......@@ -3495,7 +3529,7 @@ Array [
"context": undefined,
"line": 16,
"message": "<label> is associated with multiple controls",
"offset": 265,
"offset": 271,
"ruleId": "multiple-labeled-controls",
"selector": "label:nth-child(4)",
"severity": 2,
......@@ -3506,7 +3540,7 @@ Array [
"context": undefined,
"line": 20,
"message": "<label> is associated with multiple controls",
"offset": 324,
"offset": 330,
"ruleId": "multiple-labeled-controls",
"selector": "label:nth-child(5)",
"severity": 2,
......@@ -3517,7 +3551,7 @@ Array [
"context": undefined,
"line": 24,
"message": "<label> is associated with multiple controls",
"offset": 361,
"offset": 367,
"ruleId": "multiple-labeled-controls",
"selector": "label:nth-child(6)",
"severity": 2,
......@@ -3528,7 +3562,7 @@ Array [
"context": undefined,
"line": 28,
"message": "<label> is associated with multiple controls",
"offset": 412,
"offset": 418,
"ruleId": "multiple-labeled-controls",
"selector": "label:nth-child(7)",
"severity": 2,
......@@ -3539,7 +3573,7 @@ Array [
"context": undefined,
"line": 32,
"message": "<label> is associated with multiple controls",
"offset": 467,
"offset": 473,
"ruleId": "multiple-labeled-controls",
"selector": "label:nth-child(8)",
"severity": 2,
......@@ -3550,7 +3584,7 @@ Array [
"context": undefined,
"line": 36,
"message": "<label> is associated with multiple controls",
"offset": 530,
"offset": 536,
"ruleId": "multiple-labeled-controls",
"selector": "label:nth-child(9)",
"severity": 2,
......@@ -3561,7 +3595,7 @@ Array [
"context": undefined,
"line": 40,
"message": "<label> is associated with multiple controls",
"offset": 585,
"offset": 591,
"ruleId": "multiple-labeled-controls",
"selector": "label:nth-child(10)",
"severity": 2,
......@@ -3580,8 +3614,8 @@ Array [
<!-- should not allow multiple controls -->
<label>
<button type=\\"button\\"></button>
<button type=\\"button\\"></button>
<button type=\\"button\\">foo</button>
<button type=\\"button\\">bar</button>
</label>
<label>
<input type=\\"text\\">
......@@ -4430,9 +4464,9 @@ Array [
Object {
"column": 3,
"context": undefined,
"line": 19,
"line": 20,
"message": "Element <object> is not permitted as descendant of <button>",
"offset": 305,
"offset": 310,
"ruleId": "element-permitted-content",
"selector": "button > object",
"severity": 2,
......@@ -4457,6 +4491,7 @@ Array [
<!-- should be interactive if usemap is set -->
<button type=\\"button\\">
foo
<object usemap></object>
</button>
",
......@@ -5308,9 +5343,9 @@ Array [
Object {
"column": 4,
"context": undefined,
"line": 4,
"line": 5,
"message": "Element <input> is not permitted as descendant of <button>",
"offset": 65,
"offset": 71,
"ruleId": "element-permitted-content",
"selector": "button > slot > input",
"severity": 2,
......@@ -5320,6 +5355,7 @@ Array [
"source": "<!-- should be transparent -->
<button type=\\"button\\">
<slot>
foo
<input type=\\"text\\">
</slot>
</button>
......@@ -6574,9 +6610,9 @@ Array [
Object {
"column": 3,
"context": undefined,
"line": 22,
"line": 23,
"message": "Element <video> is not permitted as descendant of <button>",
"offset": 456,
"offset": 464,
"ruleId": "element-permitted-content",
"selector": "button > video",
"severity": 2,
......@@ -6595,9 +6631,9 @@ Array [
"element": "video",
"value": "foobar",
},
"line": 26,
"line": 27,
"message": "Attribute \\"preload\\" has invalid value \\"foobar\\"",
"offset": 557,
"offset": 565,
"ruleId": "attribute-allowed-values",
"selector": "video:nth-child(6)",
"severity": 2,
......@@ -6625,6 +6661,7 @@ Array [
<!-- should be interactive if missing controls attribute (and thus not allowed as content in button) -->
<button type=\\"button\\">
Video:
<video controls=\\"foo\\"></video>
</button>
......
......@@ -197,7 +197,8 @@
"type": ["submit", "reset", "button"]
},
"permittedContent": ["@phrasing"],
"permittedDescendants": [{ "exclude": ["@interactive"] }]
"permittedDescendants": [{ "exclude": ["@interactive"] }],
"textContent": "accessible"
},
"canvas": {
......
......@@ -13,6 +13,7 @@ const config: ConfigData = {
"no-redundant-role": "error",
"prefer-native-element": "error",
"svg-focusable": "error",
"text-content": "error",
"wcag/h30": "error",
"wcag/h32": "error",
"wcag/h36": "error",
......
......@@ -44,6 +44,7 @@ const config: ConfigData = {
"script-element": "error",
"script-type": "error",
"svg-focusable": "error",
"text-content": "error",
"unrecognized-char-ref": "error",
void: "off",
"void-content": "error",
......
......@@ -223,6 +223,7 @@ describe("toHTMLValidate()", () => {
Anchor link must have a text describing its purpose [wcag/h30]
<button> is missing required \\"type\\" attribute [element-required-attributes]
<button> must have accessible text [text-content]
Element <button> is not permitted as descendant of <a> [element-permitted-content]
Mismatched close-tag, expected '</button>' but found '</i>'. [close-order]
Missing close-tag, expected '</a>' but document ended before it was found. [close-order]"
......
......@@ -10,6 +10,20 @@ export type PermittedOrder = string[];
export type RequiredAncestors = string[];
export type RequiredContent = string[];
export enum TextContent {
/* forbid node to have text content, inter-element whitespace is ignored */
NONE = "none",
/* node can have text but not required too */
DEFAULT = "default",
/* node requires text-nodes to be present (direct or by descendant) */
REQUIRED = "required",
/* node requires accessible text (hidden text is ignored, tries to get text from accessibility tree) */
ACCESSIBLE = "accessible",
}
export interface PermittedAttribute {
[key: string]: Array<string | RegExp>;
}
......@@ -54,6 +68,7 @@ export interface MetaData {
permittedOrder?: PermittedOrder;
requiredAncestors?: RequiredAncestors;
requiredContent?: RequiredContent;
textContent?: TextContent;
}
/**
......
export { MetaTable } from "./table";
export {
ElementTable,
MetaCopyableProperty,
MetaData,
MetaDataTable,
MetaElement,
ElementTable,
MetaLookupableProperty,
MetaCopyableProperty,
PropertyExpression,
TextContent,
} from "./element";
export { Validator } from "./validator";
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`rule text-content should contain contextual documentation accessible 1`] = `
Object {
"description": "The \`<my-element>\` element must have accessible text.",
"url": "https://html-validate.org/rules/text-content.html",
}
`;
exports[`rule text-content should contain contextual documentation default 1`] = `
Object {
"description": "The textual content for this element is not valid.",
"url": "https://html-validate.org/rules/text-content.html",
}
`;
exports[`rule text-content should contain contextual documentation none 1`] = `
Object {
"description": "The \`<my-element>\` element must not have textual content.",
"url": "https://html-validate.org/rules/text-content.html",
}
`;
exports[`rule text-content should contain contextual documentation required 1`] = `
Object {
"description": "The \`<my-element>\` element must have textual content.",
"url": "https://html-validate.org/rules/text-content.html",
}
`;
exports[`rule text-content should contain documentation 1`] = `
Object {
"description": "The textual content for this element is not valid.",
"url": "https://html-validate.org/rules/text-content.html",
}
`;
......@@ -51,6 +51,7 @@ import RequireSri from "./require-sri";
import ScriptElement from "./script-element";
import ScriptType from "./script-type";
import SvgFocusable from "./svg-focusable";
import TextContent from "./text-content";
import UnrecognizedCharRef from "./unrecognized-char-ref";
import Void from "./void";
import VoidContent from "./void-content";
......@@ -110,6 +111,7 @@ const bundledRules: Record<string, RuleConstructor<any, any>> = {
"script-element": ScriptElement,
"script-type": ScriptType,
"svg-focusable": SvgFocusable,
"text-content": TextContent,
"unrecognized-char-ref": UnrecognizedCharRef,
void: Void,
"void-content": VoidContent,
......
import { DynamicValue, HtmlElement } from "../dom";
import HtmlValidate from "../htmlvalidate";
import "../matchers";
import { TextContent } from "../meta";
function processElement(node: HtmlElement): void {
if (node.hasAttribute("bind-text")) {
node.appendText(new DynamicValue(""), {
filename: "mock",
line: 1,
column: 1,
offset: 0,
size: 1,
});
}
}
describe("rule text-content", () => {
let htmlvalidate: HtmlValidate;
beforeAll(() => {
htmlvalidate = new HtmlValidate({
elements: [
{
"text-unset": {},
"text-none": {
textContent: TextContent.NONE,
},
"text-default": {
textContent: TextContent.DEFAULT,
},
"text-required": {
textContent: TextContent.REQUIRED,
},
"text-accessible": {
textContent: TextContent.ACCESSIBLE,
},
input: {
void: true,
textContent: TextContent.ACCESSIBLE,
},
},
],
rules: { "text-content": "error" },
});
});
describe("text unset", () => {
it("should not report error when meta is missing", () => {
expect.assertions(1);
const markup = [
"<text-missing></text-missing>",
"<text-missing>foobar</text-missing>",
'<text-missing><span aria-label="foobar"></span></text-missing>',
].join("");
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
it("should not report error when textContent is unset", () => {
expect.assertions(1);
const markup = [
"<text-unset></text-unset>",
"<text-unset>foobar</text-unset>",
'<text-unset><span aria-label="foobar"></span></text-unset>',
].join("");
const report = htmlvalidate.validateString(markup);
expect(report).toBeValid();
});
});
describe("text none"