Added logging, changed some directory structure

This commit is contained in:
2018-01-13 21:33:40 -05:00
parent f079a5f067
commit 8e72ffb917
73656 changed files with 35284 additions and 53718 deletions

View File

@@ -0,0 +1,47 @@
/**
* @fileoverview Enforce emojis are wrapped in <span> and provide screenreader access.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import emojiRegex from 'emoji-regex';
import { getProp, getLiteralPropValue, elementType } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const errorMessage =
'Emojis should be wrapped in <span>, have role="img", and have an accessible description with aria-label or aria-labelledby.';
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const literalChildValue = node.parent.children.find(
child => child.type === 'Literal',
);
if (literalChildValue && emojiRegex().test(literalChildValue.value)) {
const rolePropValue = getLiteralPropValue(getProp(node.attributes, 'role'));
const ariaLabelProp = getProp(node.attributes, 'aria-label');
const arialLabelledByProp = getProp(node.attributes, 'aria-labelledby');
const hasLabel = ariaLabelProp !== undefined || arialLabelledByProp !== undefined;
const isSpan = elementType(node) === 'span';
if (hasLabel === false || rolePropValue !== 'img' || isSpan === false) {
context.report({
node,
message: errorMessage,
});
}
}
},
}),
};

View File

@@ -0,0 +1,202 @@
/**
* @fileoverview Enforce all elements that require alternative text have it.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { getProp, getPropValue, elementType, getLiteralPropValue } from 'jsx-ast-utils';
import { generateObjSchema, arraySchema } from '../util/schemas';
import hasAccessibleChild from '../util/hasAccessibleChild';
import isPresentationRole from '../util/isPresentationRole';
const DEFAULT_ELEMENTS = [
'img',
'object',
'area',
'input[type="image"]',
];
const schema = generateObjSchema({
elements: arraySchema,
img: arraySchema,
object: arraySchema,
area: arraySchema,
'input[type="image"]': arraySchema,
});
const ruleByElement = {
img(context, node) {
const nodeType = elementType(node);
const altProp = getProp(node.attributes, 'alt');
// Missing alt prop error.
if (altProp === undefined) {
if (isPresentationRole(nodeType, node.attributes)) {
context.report({
node,
message: 'Prefer alt="" over a presentational role. First rule of aria is to not use aria if it can be achieved via native HTML.',
});
return;
}
context.report({
node,
message: `${nodeType} elements must have an alt prop, either with meaningful text, or an empty string for decorative images.`,
});
return;
}
// Check if alt prop is undefined.
const altValue = getPropValue(altProp);
const isNullValued = altProp.value === null; // <img alt />
if ((altValue && !isNullValued) || altValue === '') {
return;
}
// Undefined alt prop error.
context.report({
node,
message: `Invalid alt value for ${nodeType}. Use alt="" for presentational images.`,
});
},
object(context, node) {
const ariaLabelProp = getProp(node.attributes, 'aria-label');
const arialLabelledByProp = getProp(node.attributes, 'aria-labelledby');
const hasLabel = ariaLabelProp !== undefined || arialLabelledByProp !== undefined;
const titleProp = getLiteralPropValue(getProp(node.attributes, 'title'));
const hasTitleAttr = !!titleProp;
if (hasLabel || hasTitleAttr || hasAccessibleChild(node.parent)) {
return;
}
context.report({
node,
message: 'Embedded <object> elements must have alternative text by providing inner text, aria-label or aria-labelledby props.',
});
},
area(context, node) {
const ariaLabelPropValue = getPropValue(getProp(node.attributes, 'aria-label'));
const arialLabelledByPropValue = getPropValue(getProp(node.attributes, 'aria-labelledby'));
const hasLabel = ariaLabelPropValue !== undefined || arialLabelledByPropValue !== undefined;
if (hasLabel) {
return;
}
const altProp = getProp(node.attributes, 'alt');
if (altProp === undefined) {
context.report({
node,
message: 'Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop.',
});
return;
}
const altValue = getPropValue(altProp);
const isNullValued = altProp.value === null; // <area alt />
if ((altValue && !isNullValued) || altValue === '') {
return;
}
context.report({
node,
message: 'Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop.',
});
},
'input[type="image"]': function inputImage(context, node) {
// Only test input[type="image"]
const nodeType = elementType(node);
if (nodeType === 'input') {
const typePropValue = getPropValue(getProp(node.attributes, 'type'));
if (typePropValue !== 'image') { return; }
}
const ariaLabelPropValue = getPropValue(getProp(node.attributes, 'aria-label'));
const arialLabelledByPropValue = getPropValue(getProp(node.attributes, 'aria-labelledby'));
const hasLabel = ariaLabelPropValue !== undefined || arialLabelledByPropValue !== undefined;
if (hasLabel) {
return;
}
const altProp = getProp(node.attributes, 'alt');
if (altProp === undefined) {
context.report({
node,
message: '<input> elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop.',
});
return;
}
const altValue = getPropValue(altProp);
const isNullValued = altProp.value === null; // <area alt />
if ((altValue && !isNullValued) || altValue === '') {
return;
}
context.report({
node,
message: '<input> elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` prop.',
});
},
};
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: (context) => {
const options = context.options[0] || {};
// Elements to validate for alt text.
const elementOptions = options.elements || DEFAULT_ELEMENTS;
// Get custom components for just the elements that will be tested.
const customComponents = elementOptions
.map(element => options[element])
.reduce(
(components, customComponentsForElement) => components.concat(
customComponentsForElement || [],
),
[],
);
const typesToValidate = new Set([]
.concat(customComponents, ...elementOptions)
.map((type) => {
if (type === 'input[type="image"]') { return 'input'; }
return type;
}));
return {
JSXOpeningElement: (node) => {
const nodeType = elementType(node);
if (!typesToValidate.has(nodeType)) { return; }
let DOMElement = nodeType;
if (DOMElement === 'input') {
DOMElement = 'input[type="image"]';
}
// Map nodeType to the DOM element if we are running this on a custom component.
if (elementOptions.indexOf(DOMElement) === -1) {
DOMElement = elementOptions.find((element) => {
const customComponentsForElement = options[element] || [];
return customComponentsForElement.indexOf(nodeType) > -1;
});
}
ruleByElement[DOMElement](context, node);
},
};
},
};

View File

@@ -0,0 +1,46 @@
/**
* @fileoverview Enforce anchor elements to contain accessible content.
* @author Lisa Ring & Niklas Holmberg
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { elementType } from 'jsx-ast-utils';
import { arraySchema, generateObjSchema } from '../util/schemas';
import hasAccessibleChild from '../util/hasAccessibleChild';
const errorMessage =
'Anchors must have content and the content must be accessible by a screen reader.';
const schema = generateObjSchema({ components: arraySchema });
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const options = context.options[0] || {};
const componentOptions = options.components || [];
const typeCheck = ['a'].concat(componentOptions);
const nodeType = elementType(node);
// Only check anchor elements and custom types.
if (typeCheck.indexOf(nodeType) === -1) {
return;
} else if (hasAccessibleChild(node.parent)) {
return;
}
context.report({
node,
message: errorMessage,
});
},
}),
};

View File

@@ -0,0 +1,123 @@
/**
* @fileoverview Performs validity check on anchor hrefs. Warns when anchors are used as buttons.
* @author Almero Steyn
* @flow
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { elementType, getProp, getPropValue } from 'jsx-ast-utils';
import type { JSXOpeningElement } from 'ast-types-flow';
import type { ESLintContext } from '../../flow/eslint';
import { generateObjSchema, arraySchema, enumArraySchema } from '../util/schemas';
const allAspects = ['noHref', 'invalidHref', 'preferButton'];
const preferButtonErrorMessage = 'Anchor used as a button. ' +
'Anchors are primarily expected to navigate. ' +
'Use the button element instead.';
const noHrefErrorMessage = 'The href attribute is required on an anchor. ' +
'Provide a valid, navigable address as the href value.';
const invalidHrefErrorMessage = 'The href attribute requires a valid address. ' +
'Provide a valid, navigable address as the href value.';
const schema = generateObjSchema({
components: arraySchema,
specialLink: arraySchema,
aspects: enumArraySchema(allAspects, 1),
});
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: (context: ESLintContext) => ({
JSXOpeningElement: (node: JSXOpeningElement) => {
const attributes = node.attributes;
const options = context.options[0] || {};
const componentOptions = options.components || [];
const typeCheck = ['a'].concat(componentOptions);
const nodeType = elementType(node);
// Only check anchor elements and custom types.
if (typeCheck.indexOf(nodeType) === -1) {
return;
}
// Set up the rule aspects to check.
const aspects = options.aspects || allAspects;
// Create active aspect flag object. Failing checks will only report
// if the related flag is set to true.
const activeAspects = {};
allAspects.forEach((aspect) => {
activeAspects[aspect] = aspects.indexOf(aspect) !== -1;
});
const propOptions = options.specialLink || [];
const propsToValidate = ['href'].concat(propOptions);
const values = propsToValidate
.map(prop => getProp(node.attributes, prop))
.map(prop => getPropValue(prop));
// Checks if any actual or custom href prop is provided.
const hasAnyHref = values
.filter(value => value === undefined || value === null).length !== values.length;
// Need to check for spread operator as props can be spread onto the element
// leading to an incorrect validation error.
const hasSpreadOperator = attributes
.filter(prop => prop.type === 'JSXSpreadAttribute').length > 0;
const onClick = getProp(attributes, 'onClick');
// When there is no href at all, specific scenarios apply:
if (!hasAnyHref) {
// If no spread operator is found and no onClick event is present
// it is a link without href.
if (!hasSpreadOperator && activeAspects.noHref &&
(!onClick || (onClick && !activeAspects.preferButton))) {
context.report({
node,
message: noHrefErrorMessage,
});
}
// If no spread operator is found but an onClick is preset it should be a button.
if (!hasSpreadOperator && onClick && activeAspects.preferButton) {
context.report({
node,
message: preferButtonErrorMessage,
});
}
return;
}
// Hrefs have been found, now check for validity.
const invalidHrefValues = values
.filter(value => value !== undefined && value !== null)
.filter(value =>
typeof value === 'string' &&
(!value.length
|| value === '#'
|| /^\W*?javascript/.test(value)
));
if (invalidHrefValues.length !== 0) {
// If an onClick is found it should be a button, otherwise it is an invalid link.
if (onClick && activeAspects.preferButton) {
context.report({
node,
message: preferButtonErrorMessage,
});
} else if (activeAspects.invalidHref) {
context.report({
node,
message: invalidHrefErrorMessage,
});
}
}
},
}),
};

View File

@@ -0,0 +1,69 @@
/**
* @fileoverview Enforce elements with aria-activedescendant are tabbable.
* @author Jesse Beach <@jessebeach>
*/
import { dom } from 'aria-query';
import { getProp, elementType } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
import getTabIndex from '../util/getTabIndex';
import isInteractiveElement from '../util/isInteractiveElement';
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
const errorMessage =
'An element that manages focus with `aria-activedescendant` must be tabbable';
const schema = generateObjSchema();
const domElements = [...dom.keys()];
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const { attributes } = node;
if (getProp(attributes, 'aria-activedescendant') === undefined) {
return;
}
const type = elementType(node);
// Do not test higher level JSX components, as we do not know what
// low-level DOM element this maps to.
if (domElements.indexOf(type) === -1) {
return;
}
const tabIndex = getTabIndex(getProp(attributes, 'tabIndex'));
// If this is an interactive element, tabIndex must be either left
// unspecified allowing the inherent tabIndex to obtain or it must be
// zero (allowing for positive, even though that is not ideal). It cannot
// be given a negative value.
if (
isInteractiveElement(type, attributes)
&& (
tabIndex === undefined
|| tabIndex >= 0
)
) {
return;
}
if (tabIndex >= 0) {
return;
}
context.report({
node,
message: errorMessage,
});
},
}),
};

View File

@@ -0,0 +1,56 @@
/**
* @fileoverview Enforce all aria-* properties are valid.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { aria } from 'aria-query';
import { propName } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
import getSuggestion from '../util/getSuggestion';
const ariaAttributes = [...aria.keys()];
const errorMessage = (name) => {
const suggestions = getSuggestion(name, ariaAttributes);
const message = `${name}: This attribute is an invalid ARIA attribute.`;
if (suggestions.length > 0) {
return `${message} Did you mean to use ${suggestions}?`;
}
return message;
};
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXAttribute: (attribute) => {
const name = propName(attribute);
const normalizedName = name ? name.toLowerCase() : '';
// `aria` needs to be prefix of property.
if (normalizedName.indexOf('aria-') !== 0) {
return;
}
const isValid = ariaAttributes.indexOf(normalizedName) > -1;
if (isValid === false) {
context.report({
node: attribute,
message: errorMessage(name),
});
}
},
}),
};

View File

@@ -0,0 +1,99 @@
/**
* @fileoverview Enforce ARIA state and property values are valid.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { aria } from 'aria-query';
import { getLiteralPropValue, propName } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const errorMessage = (name, type, permittedValues) => {
switch (type) {
case 'tristate':
return `The value for ${name} must be a boolean or the string "mixed".`;
case 'token':
return `The value for ${name} must be a single token from the following: ${permittedValues}.`;
case 'tokenlist':
return `The value for ${name} must be a list of one or more \
tokens from the following: ${permittedValues}.`;
case 'boolean':
case 'string':
case 'integer':
case 'number':
default:
return `The value for ${name} must be a ${type}.`;
}
};
const validityCheck = (value, expectedType, permittedValues) => {
switch (expectedType) {
case 'boolean':
return typeof value === 'boolean';
case 'string':
return typeof value === 'string';
case 'tristate':
return typeof value === 'boolean' || value === 'mixed';
case 'integer':
case 'number':
// Booleans resolve to 0/1 values so hard check that it's not first.
return typeof value !== 'boolean' && isNaN(Number(value)) === false;
case 'token':
return permittedValues.indexOf(typeof value === 'string' ? value.toLowerCase() : value) > -1;
case 'tokenlist':
return typeof value === 'string' &&
value.split(' ').every(token => permittedValues.indexOf(token.toLowerCase()) > -1);
default:
return false;
}
};
const schema = generateObjSchema();
module.exports = {
validityCheck,
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXAttribute: (attribute) => {
const name = propName(attribute);
const normalizedName = name ? name.toLowerCase() : '';
// Not a valid aria-* state or property.
if (normalizedName.indexOf('aria-') !== 0 || aria.get(normalizedName) === undefined) {
return;
}
const value = getLiteralPropValue(attribute);
// We only want to check literal prop values, so just pass if it's null.
if (value === null) {
return;
}
// These are the attributes of the property/state to check against.
const attributes = aria.get(normalizedName);
const permittedType = attributes.type;
const allowUndefined = attributes.allowUndefined || false;
const permittedValues = attributes.values || [];
const isValid = validityCheck(value, permittedType, permittedValues) ||
(allowUndefined && value === undefined);
if (isValid) {
return;
}
context.report({
node: attribute,
message: errorMessage(name, permittedType, permittedValues),
});
},
}),
};

View File

@@ -0,0 +1,70 @@
/**
* @fileoverview Enforce aria role attribute is valid.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { dom, roles } from 'aria-query';
import { getLiteralPropValue, propName, elementType } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const errorMessage = 'Elements with ARIA roles must use a valid, non-abstract ARIA role.';
const schema = generateObjSchema({
ignoreNonDOM: {
type: 'boolean',
default: false,
},
});
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXAttribute: (attribute) => {
// Determine if ignoreNonDOM is set to true
// If true, then do not run rule.
const options = context.options[0] || {};
const ignoreNonDOM = !!options.ignoreNonDOM;
if (ignoreNonDOM) {
const type = elementType(attribute.parent);
if (!dom.get(type)) {
return;
}
}
// Get prop name
const name = propName(attribute);
const normalizedName = name ? name.toUpperCase() : '';
if (normalizedName !== 'ROLE') { return; }
const value = getLiteralPropValue(attribute);
// If value is undefined, then the role attribute will be dropped in the DOM.
// If value is null, then getLiteralAttributeValue is telling us that the
// value isn't in the form of a literal.
if (value === undefined || value === null) { return; }
const normalizedValues = String(value).toLowerCase().split(' ');
const validRoles = [...roles.keys()].filter(
role => roles.get(role).abstract === false,
);
const isValid = normalizedValues.every(val => validRoles.indexOf(val) > -1);
if (isValid === true) { return; }
context.report({
node: attribute,
message: errorMessage,
});
},
}),
};

View File

@@ -0,0 +1,62 @@
/**
* @fileoverview Enforce that elements that do not support ARIA roles,
* states and properties do not have those attributes.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import {
aria,
dom,
} from 'aria-query';
import { elementType, propName } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const errorMessage = invalidProp =>
`This element does not support ARIA roles, states and properties. \
Try removing the prop '${invalidProp}'.`;
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const nodeType = elementType(node);
const nodeAttrs = dom.get(nodeType) || {};
const {
reserved: isReservedNodeType = false,
} = nodeAttrs;
// If it's not reserved, then it can have aria-* roles, states, and properties
if (isReservedNodeType === false) {
return;
}
const invalidAttributes = [...aria.keys()].concat('role');
node.attributes.forEach((prop) => {
if (prop.type === 'JSXSpreadAttribute') {
return;
}
const name = propName(prop);
const normalizedName = name ? name.toLowerCase() : '';
if (invalidAttributes.indexOf(normalizedName) > -1) {
context.report({
node,
message: errorMessage(name),
});
}
});
},
}),
};

View File

@@ -0,0 +1,60 @@
/**
* @fileoverview Enforce a clickable non-interactive element has at least 1 keyboard event listener.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import {
dom,
} from 'aria-query';
import { getProp, hasAnyProp, elementType } from 'jsx-ast-utils';
import includes from 'array-includes';
import { generateObjSchema } from '../util/schemas';
import isHiddenFromScreenReader from '../util/isHiddenFromScreenReader';
import isInteractiveElement from '../util/isInteractiveElement';
const errorMessage = 'Visible, non-interactive elements with click handlers' +
' must have at least one keyboard listener.';
const schema = generateObjSchema();
const domElements = [...dom.keys()];
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const props = node.attributes;
if (getProp(props, 'onclick') === undefined) {
return;
}
const type = elementType(node);
const requiredProps = ['onkeydown', 'onkeyup', 'onkeypress'];
if (!includes(domElements, type)) {
// Do not test higher level JSX components, as we do not know what
// low-level DOM element this maps to.
return;
} else if (isHiddenFromScreenReader(type, props)) {
return;
} else if (isInteractiveElement(type, props)) {
return;
} else if (hasAnyProp(props, requiredProps)) {
return;
}
// Visible, non-interactive elements with click handlers require one keyboard event listener.
context.report({
node,
message: errorMessage,
});
},
}),
};

View File

@@ -0,0 +1,52 @@
/**
* @fileoverview Enforce heading (h1, h2, etc) elements contain accessible content.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { elementType } from 'jsx-ast-utils';
import { generateObjSchema, arraySchema } from '../util/schemas';
import hasAccessibleChild from '../util/hasAccessibleChild';
const errorMessage =
'Headings must have content and the content must be accessible by a screen reader.';
const headings = [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
];
const schema = generateObjSchema({ components: arraySchema });
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const typeCheck = headings.concat(context.options[0]);
const nodeType = elementType(node);
// Only check 'h*' elements and custom types.
if (typeCheck.indexOf(nodeType) === -1) {
return;
} else if (hasAccessibleChild(node.parent)) {
return;
}
context.report({
node,
message: errorMessage,
});
},
}),
};

View File

@@ -0,0 +1,55 @@
/**
* @fileoverview Enforce links may not point to just #.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { getProp, getPropValue, elementType } from 'jsx-ast-utils';
import { generateObjSchema, arraySchema } from '../util/schemas';
const errorMessage = 'Links must not point to "#". ' +
'Use a more descriptive href or use a button instead.';
const schema = generateObjSchema({
components: arraySchema,
specialLink: arraySchema,
});
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const options = context.options[0] || {};
const componentOptions = options.components || [];
const typesToValidate = ['a'].concat(componentOptions);
const nodeType = elementType(node);
// Only check 'a' elements and custom types.
if (typesToValidate.indexOf(nodeType) === -1) {
return;
}
const propOptions = options.specialLink || [];
const propsToValidate = ['href'].concat(propOptions);
const values = propsToValidate
.map(prop => getProp(node.attributes, prop))
.map(prop => getPropValue(prop));
values.forEach((value) => {
if (value === '#') {
context.report({
node,
message: errorMessage,
});
}
});
},
}),
};

View File

@@ -0,0 +1,43 @@
/**
* @fileoverview Enforce html element has lang prop.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { elementType, getProp, getPropValue } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const errorMessage = '<html> elements must have the lang prop.';
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const type = elementType(node);
if (type && type !== 'html') {
return;
}
const lang = getPropValue(getProp(node.attributes, 'lang'));
if (lang) {
return;
}
context.report({
node,
message: errorMessage,
});
},
}),
};

View File

@@ -0,0 +1,43 @@
/**
* @fileoverview Enforce iframe elements have a title attribute.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { elementType, getProp, getPropValue } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const errorMessage = '<iframe> elements must have a unique title property.';
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const type = elementType(node);
if (type && type !== 'iframe') {
return;
}
const title = getPropValue(getProp(node.attributes, 'title'));
if (title && typeof title === 'string') {
return;
}
context.report({
node,
message: errorMessage,
});
},
}),
};

View File

@@ -0,0 +1,74 @@
/**
* @fileoverview Enforce img alt attribute does not have the word image, picture, or photo.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { getProp, getLiteralPropValue, elementType } from 'jsx-ast-utils';
import { generateObjSchema, arraySchema } from '../util/schemas';
import isHiddenFromScreenReader from '../util/isHiddenFromScreenReader';
const REDUNDANT_WORDS = [
'image',
'photo',
'picture',
];
const errorMessage = 'Redundant alt attribute. Screen-readers already announce ' +
'`img` tags as an image. You don\'t need to use the words `image`, ' +
'`photo,` or `picture` (or any specified custom words) in the alt prop.';
const schema = generateObjSchema({
components: arraySchema,
words: arraySchema,
});
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const options = context.options[0] || {};
const componentOptions = options.components || [];
const typesToValidate = ['img'].concat(componentOptions);
const nodeType = elementType(node);
// Only check 'label' elements and custom types.
if (typesToValidate.indexOf(nodeType) === -1) {
return;
}
const altProp = getProp(node.attributes, 'alt');
// Return if alt prop is not present.
if (altProp === undefined) {
return;
}
const value = getLiteralPropValue(altProp);
const isVisible = isHiddenFromScreenReader(nodeType, node.attributes) === false;
const {
words = [],
} = options;
const redundantWords = REDUNDANT_WORDS.concat(words);
if (typeof value === 'string' && isVisible) {
const hasRedundancy = redundantWords
.some(word => Boolean(value.match(new RegExp(`(?!{)\\b${word}\\b(?!})`, 'i'))));
if (hasRedundancy === true) {
context.report({
node,
message: errorMessage,
});
}
}
},
}),
};

View File

@@ -0,0 +1,117 @@
/**
* @fileoverview Enforce that elements with onClick handlers must be tabbable.
* @author Ethan Cohen
* @flow
*/
import {
dom,
roles,
} from 'aria-query';
import {
getProp,
elementType,
eventHandlersByType,
getLiteralPropValue,
hasAnyProp,
} from 'jsx-ast-utils';
import type { JSXOpeningElement } from 'ast-types-flow';
import includes from 'array-includes';
import type { ESLintContext } from '../../flow/eslint';
import {
enumArraySchema,
generateObjSchema,
} from '../util/schemas';
import isHiddenFromScreenReader from '../util/isHiddenFromScreenReader';
import isInteractiveElement from '../util/isInteractiveElement';
import isInteractiveRole from '../util/isInteractiveRole';
import isNonInteractiveElement from '../util/isNonInteractiveElement';
import isNonInteractiveRole from '../util/isNonInteractiveRole';
import isPresentationRole from '../util/isPresentationRole';
import getTabIndex from '../util/getTabIndex';
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
const schema = generateObjSchema({
tabbable: enumArraySchema(
[...roles.keys()]
.filter(name => !roles.get(name).abstract)
.filter(name => roles.get(name).superClass.some(
klasses => includes(klasses, 'widget')),
),
),
});
const domElements = [...dom.keys()];
const interactiveProps = [
...eventHandlersByType.mouse,
...eventHandlersByType.keyboard,
];
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: (context: ESLintContext & {
options: {
tabbable: Array<string>
}
}) => ({
JSXOpeningElement: (
node: JSXOpeningElement,
) => {
const tabbable = (
context.options && context.options[0] && context.options[0].tabbable
) || [];
const attributes = node.attributes;
const type = elementType(node);
const hasInteractiveProps = hasAnyProp(attributes, interactiveProps);
const hasTabindex = getTabIndex(
getProp(attributes, 'tabIndex'),
) !== undefined;
if (!includes(domElements, type)) {
// Do not test higher level JSX components, as we do not know what
// low-level DOM element this maps to.
return;
} else if (
!hasInteractiveProps
|| isHiddenFromScreenReader(type, attributes)
|| isPresentationRole(type, attributes)
) {
// Presentation is an intentional signal from the author that this
// element is not meant to be perceivable. For example, a click screen
// to close a dialog .
return;
}
if (
hasInteractiveProps
&& isInteractiveRole(type, attributes)
&& !isInteractiveElement(type, attributes)
&& !isNonInteractiveElement(type, attributes)
&& !isNonInteractiveRole(type, attributes)
&& !hasTabindex
) {
const role = getLiteralPropValue(getProp(attributes, 'role'));
if (includes(tabbable, role)) {
// Always tabbable, tabIndex = 0
context.report({
node,
message: `Elements with the '${role}' interactive role must be tabbable.`,
});
} else {
// Focusable, tabIndex = -1 or 0
context.report({
node,
message: `Elements with the '${role}' interactive role must be focusable.`,
});
}
}
},
}),
};

View File

@@ -0,0 +1,79 @@
/**
* @fileoverview Enforce label tags have htmlFor attribute.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { getProp, getPropValue, elementType } from 'jsx-ast-utils';
import { generateObjSchema, arraySchema, enumArraySchema } from '../util/schemas';
const errorMessage = 'Form label must have associated control';
const enumValues = ['nesting', 'id'];
const schema = {
type: 'object',
properties: {
components: arraySchema,
required: {
oneOf: [
{ type: 'string', enum: enumValues },
generateObjSchema({ some: enumArraySchema(enumValues) }, ['some']),
generateObjSchema({ every: enumArraySchema(enumValues) }, ['every']),
],
},
},
};
const validateNesting = node => !!node.parent.children.find(child => child.type === 'JSXElement');
const validateId = (node) => {
const htmlForAttr = getProp(node.attributes, 'htmlFor');
const htmlForValue = getPropValue(htmlForAttr);
return htmlForAttr !== false && !!htmlForValue;
};
const validate = (node, required) => (
required === 'nesting' ? validateNesting(node) : validateId(node)
);
const isValid = (node, required) => {
if (Array.isArray(required.some)) {
return required.some.some(rule => validate(node, rule));
} else if (Array.isArray(required.every)) {
return required.every.every(rule => validate(node, rule));
}
return validate(node, required);
};
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const options = context.options[0] || {};
const componentOptions = options.components || [];
const typesToValidate = ['label'].concat(componentOptions);
const nodeType = elementType(node);
// Only check 'label' elements and custom types.
if (typesToValidate.indexOf(nodeType) === -1) {
return;
}
const required = options.required || 'id';
if (!isValid(node, required)) {
context.report({
node,
message: errorMessage,
});
}
},
}),
};

View File

@@ -0,0 +1,67 @@
/**
* @fileoverview Enforce lang attribute has a valid value.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { propName, elementType, getLiteralPropValue } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
import ISO_CODES from '../util/attributes/ISO.json';
const errorMessage =
'lang attribute must have a valid value.';
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXAttribute: (node) => {
const name = propName(node);
if (name && name.toUpperCase() !== 'LANG') {
return;
}
const { parent } = node;
const type = elementType(parent);
if (type && type !== 'html') {
return;
}
const value = getLiteralPropValue(node);
// Don't check identifiers
if (value === null) {
return;
} else if (value === undefined) {
context.report({
node,
message: errorMessage,
});
return;
}
const hyphen = value.indexOf('-');
const lang = hyphen > -1 ? value.substring(0, hyphen) : value;
const country = hyphen > -1 ? value.substring(3) : undefined;
if (ISO_CODES.languages.indexOf(lang) > -1
&& (country === undefined || ISO_CODES.countries.indexOf(country) > -1)) {
return;
}
context.report({
node,
message: errorMessage,
});
},
}),
};

View File

@@ -0,0 +1,84 @@
/**
* @fileoverview <audio> and <video> elements must have a <track> for captions.
* @author Ethan Cohen
* @flow
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import type { JSXElement, JSXOpeningElement } from 'ast-types-flow';
import { elementType, getProp, getLiteralPropValue } from 'jsx-ast-utils';
import type { ESLintContext } from '../../flow/eslint';
import { generateObjSchema, arraySchema } from '../util/schemas';
const errorMessage = 'Media elements such as <audio> and <video> must have a <track> for captions.';
const MEDIA_TYPES = ['audio', 'video'];
const schema = generateObjSchema({
audio: arraySchema,
video: arraySchema,
track: arraySchema,
});
const isMediaType = (context, type) => {
const options = context.options[0] || {};
return MEDIA_TYPES.map(mediaType => options[mediaType])
.reduce((types, customComponent) => types.concat(customComponent), MEDIA_TYPES)
.some(typeToCheck => typeToCheck === type);
};
const isTrackType = (context, type) => {
const options = context.options[0] || {};
return ['track'].concat(options.track || []).some(typeToCheck => typeToCheck === type);
};
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: (context: ESLintContext) => ({
JSXElement: (node: JSXElement) => {
const element: JSXOpeningElement = node.openingElement;
const type = elementType(element);
if (!isMediaType(context, type)) {
return;
}
// $FlowFixMe https://github.com/facebook/flow/issues/1414
const trackChildren: Array<JSXElement> = node.children.filter((child: Node) => {
if (child.type !== 'JSXElement') {
return false;
}
// $FlowFixMe https://github.com/facebook/flow/issues/1414
return isTrackType(context, elementType(child.openingElement));
});
if (trackChildren.length === 0) {
context.report({
node: element,
message: errorMessage,
});
return;
}
const hasCaption: boolean = trackChildren.some((track) => {
const kindProp = getProp(track.openingElement.attributes, 'kind');
const kindPropValue = getLiteralPropValue(kindProp) || '';
return kindPropValue.toLowerCase() === 'captions';
});
if (!hasCaption) {
context.report({
node: element,
message: errorMessage,
});
}
},
}),
};

View File

@@ -0,0 +1,61 @@
/**
* @fileoverview Enforce onmouseover/onmouseout are
* accompanied by onfocus/onblur.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { getProp, getPropValue } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const mouseOverErrorMessage = 'onMouseOver must be accompanied by onFocus for accessibility.';
const mouseOutErrorMessage = 'onMouseOut must be accompanied by onBlur for accessibility.';
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const attributes = node.attributes;
// Check onmouseover / onfocus pairing.
const onMouseOver = getProp(attributes, 'onMouseOver');
const onMouseOverValue = getPropValue(onMouseOver);
if (onMouseOver && (onMouseOverValue !== null || onMouseOverValue !== undefined)) {
const hasOnFocus = getProp(attributes, 'onFocus');
const onFocusValue = getPropValue(hasOnFocus);
if (hasOnFocus === false || onFocusValue === null || onFocusValue === undefined) {
context.report({
node,
message: mouseOverErrorMessage,
});
}
}
// Checkout onmouseout / onblur pairing
const onMouseOut = getProp(attributes, 'onMouseOut');
const onMouseOutValue = getPropValue(onMouseOut);
if (onMouseOut && (onMouseOutValue !== null || onMouseOutValue !== undefined)) {
const hasOnBlur = getProp(attributes, 'onBlur');
const onBlurValue = getPropValue(hasOnBlur);
if (hasOnBlur === false || onBlurValue === null || onBlurValue === undefined) {
context.report({
node,
message: mouseOutErrorMessage,
});
}
}
},
}),
};

View File

@@ -0,0 +1,38 @@
/**
* @fileoverview Enforce no accesskey attribute on element.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { getProp, getPropValue } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const errorMessage = 'No access key attribute allowed. Inconsistencies ' +
'between keyboard shortcuts and keyboard comments used by screenreader ' +
'and keyboard only users create a11y complications.';
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const accessKey = getProp(node.attributes, 'accesskey');
const accessKeyValue = getPropValue(accessKey);
if (accessKey && accessKeyValue) {
context.report({
node,
message: errorMessage,
});
}
},
}),
};

View File

@@ -0,0 +1,53 @@
/**
* @fileoverview Enforce autoFocus prop is not used.
* @author Ethan Cohen <@evcohen>
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { propName, elementType } from 'jsx-ast-utils';
import { dom } from 'aria-query';
import { generateObjSchema } from '../util/schemas';
const errorMessage =
'The autoFocus prop should not be used, as it can reduce usability and accessibility for users.';
const schema = generateObjSchema({
ignoreNonDOM: {
type: 'boolean',
default: false,
},
});
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXAttribute: (attribute) => {
// Determine if ignoreNonDOM is set to true
// If true, then do not run rule.
const options = context.options[0] || {};
const ignoreNonDOM = !!options.ignoreNonDOM;
if (ignoreNonDOM) {
const type = elementType(attribute.parent);
if (!dom.get(type)) {
return;
}
}
// Don't normalize, since React only recognizes autoFocus on low-level DOM elements.
if (propName(attribute) === 'autoFocus') {
context.report({
node: attribute,
message: errorMessage,
});
}
},
}),
};

View File

@@ -0,0 +1,47 @@
/**
* @fileoverview Enforce distracting elements are not used.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { elementType } from 'jsx-ast-utils';
import { generateObjSchema, enumArraySchema } from '../util/schemas';
const errorMessage = element =>
`Do not use <${element}> elements as they can create visual accessibility issues and are deprecated.`;
const DEFAULT_ELEMENTS = [
'marquee',
'blink',
];
const schema = generateObjSchema({
elements: enumArraySchema(DEFAULT_ELEMENTS),
});
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const options = context.options[0] || {};
const elementOptions = options.elements || DEFAULT_ELEMENTS;
const type = elementType(node);
const distractingElement = elementOptions.find(element => type === element);
if (distractingElement) {
context.report({
node,
message: errorMessage(distractingElement),
});
}
},
}),
};

View File

@@ -0,0 +1,94 @@
/**
* @fileoverview Disallow inherently interactive elements to be assigned
* non-interactive roles.
* @author Jesse Beach
* @flow
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import {
dom,
} from 'aria-query';
import {
elementType,
getProp,
getLiteralPropValue,
propName,
} from 'jsx-ast-utils';
import type { JSXIdentifier } from 'ast-types-flow';
import includes from 'array-includes';
import type { ESLintContext } from '../../flow/eslint';
import type { ESLintJSXAttribute } from '../../flow/eslint-jsx';
import isInteractiveElement from '../util/isInteractiveElement';
import isNonInteractiveRole from '../util/isNonInteractiveRole';
import isPresentationRole from '../util/isPresentationRole';
const errorMessage =
'Interactive elements should not be assigned non-interactive roles.';
const domElements = [...dom.keys()];
module.exports = {
meta: {
docs: {},
schema: [{
type: 'object',
additionalProperties: {
type: 'array',
items: {
type: 'string',
},
uniqueItems: true,
},
}],
},
create: (context: ESLintContext) => {
const options = context.options;
return {
JSXAttribute: (
attribute: ESLintJSXAttribute,
) => {
const attributeName: JSXIdentifier = propName(attribute);
if (attributeName !== 'role') {
return;
}
const node = attribute.parent;
const attributes = node.attributes;
const type = elementType(node);
const role = getLiteralPropValue(getProp(node.attributes, 'role'));
if (!includes(domElements, type)) {
// Do not test higher level JSX components, as we do not know what
// low-level DOM element this maps to.
return;
}
// Allow overrides from rule configuration for specific elements and
// roles.
const allowedRoles = (options[0] || {});
if (
Object.prototype.hasOwnProperty.call(allowedRoles, type)
&& includes(allowedRoles[type], role)
) {
return;
}
if (
isInteractiveElement(type, attributes)
&& (
isNonInteractiveRole(type, attributes)
|| isPresentationRole(type, attributes)
)
) {
// Visible, non-interactive elements should not have an interactive handler.
context.report({
node: attribute,
message: errorMessage,
});
}
},
};
},
};

View File

@@ -0,0 +1,99 @@
/**
* @fileoverview Enforce non-interactive elements have no interactive handlers.
* @author Jese Beach
* @flow
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import {
dom,
} from 'aria-query';
import {
elementType,
eventHandlers,
getPropValue,
getProp,
hasProp,
} from 'jsx-ast-utils';
import type { JSXOpeningElement } from 'ast-types-flow';
import includes from 'array-includes';
import type { ESLintContext } from '../../flow/eslint';
import { arraySchema, generateObjSchema } from '../util/schemas';
import isAbstractRole from '../util/isAbstractRole';
import isHiddenFromScreenReader from '../util/isHiddenFromScreenReader';
import isInteractiveElement from '../util/isInteractiveElement';
import isInteractiveRole from '../util/isInteractiveRole';
import isNonInteractiveElement from '../util/isNonInteractiveElement';
import isNonInteractiveRole from '../util/isNonInteractiveRole';
import isPresentationRole from '../util/isPresentationRole';
const errorMessage =
'Non-interactive elements should not be assigned mouse or keyboard event listeners.';
const domElements = [...dom.keys()];
const defaultInteractiveProps = eventHandlers;
const schema = generateObjSchema({
handlers: arraySchema,
});
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: (context: ESLintContext) => {
const options = context.options;
return {
JSXOpeningElement: (
node: JSXOpeningElement,
) => {
const attributes = node.attributes;
const type = elementType(node);
const interactiveProps = options[0]
? options[0].handlers
: defaultInteractiveProps;
const hasInteractiveProps = interactiveProps
.some(prop => (
hasProp(attributes, prop)
&& getPropValue(getProp(attributes, prop)) != null
));
if (!includes(domElements, type)) {
// Do not test higher level JSX components, as we do not know what
// low-level DOM element this maps to.
return;
} else if (
!hasInteractiveProps
|| isHiddenFromScreenReader(type, attributes)
|| isPresentationRole(type, attributes)
) {
// Presentation is an intentional signal from the author that this
// element is not meant to be perceivable. For example, a click screen
// to close a dialog .
return;
} else if (
isInteractiveElement(type, attributes)
|| isInteractiveRole(type, attributes)
|| (
!isNonInteractiveElement(type, attributes)
&& !isNonInteractiveRole(type, attributes)
)
|| isAbstractRole(type, attributes)
) {
// This rule has no opinion about abtract roles.
return;
}
// Visible, non-interactive elements should not have an interactive handler.
context.report({
node,
message: errorMessage,
});
},
};
},
};

View File

@@ -0,0 +1,91 @@
/**
* @fileoverview Disallow inherently non-interactive elements to be assigned
* interactive roles.
* @author Jesse Beach
* @flow
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import {
dom,
} from 'aria-query';
import {
elementType,
getProp,
getLiteralPropValue,
propName,
} from 'jsx-ast-utils';
import type {
JSXIdentifier,
} from 'ast-types-flow';
import includes from 'array-includes';
import type { ESLintContext } from '../../flow/eslint';
import type { ESLintJSXAttribute } from '../../flow/eslint-jsx';
import isNonInteractiveElement from '../util/isNonInteractiveElement';
import isInteractiveRole from '../util/isInteractiveRole';
const errorMessage =
'Non-interactive elements should not be assigned interactive roles.';
const domElements = [...dom.keys()];
module.exports = {
meta: {
docs: {},
schema: [{
type: 'object',
additionalProperties: {
type: 'array',
items: {
type: 'string',
},
uniqueItems: true,
},
}],
},
create: (context: ESLintContext) => {
const options = context.options;
return {
JSXAttribute: (
attribute: ESLintJSXAttribute,
) => {
const attributeName: JSXIdentifier = propName(attribute);
if (attributeName !== 'role') {
return;
}
const node = attribute.parent;
const attributes = node.attributes;
const type = elementType(node);
const role = getLiteralPropValue(getProp(node.attributes, 'role'));
if (!includes(domElements, type)) {
// Do not test higher level JSX components, as we do not know what
// low-level DOM element this maps to.
return;
}
// Allow overrides from rule configuration for specific elements and
// roles.
const allowedRoles = (options[0] || {});
if (
Object.prototype.hasOwnProperty.call(allowedRoles, type)
&& includes(allowedRoles[type], role)
) {
return;
}
if (
isNonInteractiveElement(type, attributes)
&& isInteractiveRole(type, attributes)
) {
context.report({
node: attribute,
message: errorMessage,
});
}
},
};
},
};

View File

@@ -0,0 +1,101 @@
/**
* @fileoverview Disallow tabindex on static and noninteractive elements
* @author jessebeach
* @flow
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import {
dom,
} from 'aria-query';
import type {
JSXOpeningElement,
} from 'ast-types-flow';
import {
elementType,
getProp,
getLiteralPropValue,
} from 'jsx-ast-utils';
import includes from 'array-includes';
import type { ESLintContext } from '../../flow/eslint';
import isInteractiveElement from '../util/isInteractiveElement';
import isInteractiveRole from '../util/isInteractiveRole';
import { generateObjSchema, arraySchema } from '../util/schemas';
import getTabIndex from '../util/getTabIndex';
const errorMessage =
'`tabIndex` should only be declared on interactive elements.';
const schema = generateObjSchema({
roles: {
...arraySchema,
description: 'An array of ARIA roles',
},
tags: {
...arraySchema,
description: 'An array of HTML tag names',
},
});
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: (context: ESLintContext) => {
const options = context.options;
return {
JSXOpeningElement: (
node: JSXOpeningElement,
) => {
const type = elementType(node);
const attributes = node.attributes;
const tabIndexProp = getProp(attributes, 'tabIndex');
const tabIndex = getTabIndex(tabIndexProp);
// Early return;
if (typeof tabIndex === 'undefined') {
return;
}
const role = getLiteralPropValue(
getProp(node.attributes, 'role'),
);
if (!dom.has(type)) {
// Do not test higher level JSX components, as we do not know what
// low-level DOM element this maps to.
return;
}
// Allow for configuration overrides.
const {
tags,
roles,
} = (options[0] || {});
if (
(tags && includes(tags, type))
|| (roles && includes(roles, role))
) {
return;
}
if (
isInteractiveElement(type, attributes)
|| isInteractiveRole(type, attributes)
) {
return;
}
if (
tabIndex >= 0
) {
context.report({
node: tabIndexProp,
message: errorMessage,
});
}
},
};
},
};

View File

@@ -0,0 +1,49 @@
/**
* @fileoverview Enforce usage of onBlur over onChange for accessibility.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { getProp, elementType } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const errorMessage = 'onBlur must be used instead of onchange, ' +
'unless absolutely necessary and it causes no negative consequences ' +
'for keyboard only or screen reader users.';
const applicableTypes = [
'select',
'option',
];
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const nodeType = elementType(node);
if (applicableTypes.indexOf(nodeType) === -1) {
return;
}
const onChange = getProp(node.attributes, 'onChange');
const hasOnBlur = getProp(node.attributes, 'onBlur') !== undefined;
if (onChange && !hasOnBlur) {
context.report({
node,
message: errorMessage,
});
}
},
}),
};

View File

@@ -0,0 +1,46 @@
/**
* @fileoverview Enforce explicit role property is not the
* same as implicit/default role property on element.
* @author Ethan Cohen <@evcohen>
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { elementType, getProp, getLiteralPropValue } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
import getImplicitRole from '../util/getImplicitRole';
const errorMessage = (element, implicitRole) =>
`The element ${element} has an implicit role of ${implicitRole}. Defining this explicitly is redundant and should be avoided.`;
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
const type = elementType(node);
const implicitRole = getImplicitRole(type, node.attributes);
if (implicitRole === '') {
return;
}
const role = getProp(node.attributes, 'role');
const roleValue = getLiteralPropValue(role);
if (typeof roleValue === 'string' && roleValue.toUpperCase() === implicitRole.toUpperCase()) {
context.report({
node,
message: errorMessage(type, implicitRole.toLowerCase()),
});
}
},
}),
};

View File

@@ -0,0 +1,98 @@
/**
* @fileoverview Enforce static elements have no interactive handlers.
* @author Ethan Cohen
* @flow
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import {
dom,
} from 'aria-query';
import {
elementType,
eventHandlers,
getPropValue,
getProp,
hasProp,
} from 'jsx-ast-utils';
import type { JSXOpeningElement } from 'ast-types-flow';
import includes from 'array-includes';
import type { ESLintContext } from '../../flow/eslint';
import { arraySchema, generateObjSchema } from '../util/schemas';
import isAbstractRole from '../util/isAbstractRole';
import isHiddenFromScreenReader from '../util/isHiddenFromScreenReader';
import isInteractiveElement from '../util/isInteractiveElement';
import isInteractiveRole from '../util/isInteractiveRole';
import isNonInteractiveElement from '../util/isNonInteractiveElement';
import isNonInteractiveRole from '../util/isNonInteractiveRole';
import isPresentationRole from '../util/isPresentationRole';
const errorMessage =
'Static HTML elements with event handlers require a role.';
const domElements = [...dom.keys()];
const defaultInteractiveProps = eventHandlers;
const schema = generateObjSchema({
handlers: arraySchema,
});
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: (context: ESLintContext) => {
const options = context.options;
return {
JSXOpeningElement: (
node: JSXOpeningElement,
) => {
const attributes = node.attributes;
const type = elementType(node);
const interactiveProps = options[0]
? options[0].handlers
: defaultInteractiveProps;
const hasInteractiveProps = interactiveProps
.some(prop => (
hasProp(attributes, prop)
&& getPropValue(getProp(attributes, prop)) != null
));
if (!includes(domElements, type)) {
// Do not test higher level JSX components, as we do not know what
// low-level DOM element this maps to.
return;
} else if (
!hasInteractiveProps
|| isHiddenFromScreenReader(type, attributes)
|| isPresentationRole(type, attributes)
) {
// Presentation is an intentional signal from the author that this
// element is not meant to be perceivable. For example, a click screen
// to close a dialog .
return;
} else if (
isInteractiveElement(type, attributes)
|| isInteractiveRole(type, attributes)
|| isNonInteractiveElement(type, attributes)
|| isNonInteractiveRole(type, attributes)
|| isAbstractRole(type, attributes)
) {
// This rule has no opinion about abstract roles.
return;
}
// Visible, non-interactive elements should not have an interactive handler.
context.report({
node,
message: errorMessage,
});
},
};
},
};

View File

@@ -0,0 +1,68 @@
/**
* @fileoverview Enforce that elements with ARIA roles must
* have all required attributes for that role.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { roles } from 'aria-query';
import { getProp, getLiteralPropValue, propName } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const errorMessage = (role, requiredProps) =>
`Elements with the ARIA role "${role}" must have the following ` +
`attributes defined: ${String(requiredProps).toLowerCase()}`;
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXAttribute: (attribute) => {
const name = propName(attribute);
const normalizedName = name ? name.toLowerCase() : '';
if (normalizedName !== 'role') {
return;
}
const value = getLiteralPropValue(attribute);
// If value is undefined, then the role attribute will be dropped in the DOM.
// If value is null, then getLiteralAttributeValue is telling us
// that the value isn't in the form of a literal.
if (value === undefined || value === null) {
return;
}
const normalizedValues = String(value).toLowerCase().split(' ');
const validRoles = normalizedValues
.filter(val => [...roles.keys()].indexOf(val) > -1);
validRoles.forEach((role) => {
const {
requiredProps: requiredPropKeyValues,
} = roles.get(role);
const requiredProps = Object.keys(requiredPropKeyValues);
if (requiredProps.length > 0) {
const hasRequiredProps = requiredProps
.every(prop => getProp(attribute.parent.attributes, prop));
if (hasRequiredProps === false) {
context.report({
node: attribute,
message: errorMessage(role.toLowerCase(), requiredProps),
});
}
}
});
},
}),
};

View File

@@ -0,0 +1,77 @@
/**
* @fileoverview Enforce that elements with explicit or implicit roles defined contain only
* `aria-*` properties supported by that `role`.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import {
aria,
roles,
} from 'aria-query';
import { getProp, getLiteralPropValue, elementType, propName } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
import getImplicitRole from '../util/getImplicitRole';
const errorMessage = (attr, role, tag, isImplicit) => {
if (isImplicit) {
return `The attribute ${attr} is not supported by the role ${role}. \
This role is implicit on the element ${tag}.`;
}
return `The attribute ${attr} is not supported by the role ${role}.`;
};
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXOpeningElement: (node) => {
// If role is not explicitly defined, then try and get its implicit role.
const type = elementType(node);
const role = getProp(node.attributes, 'role');
const roleValue = role ? getLiteralPropValue(role) : getImplicitRole(type, node.attributes);
const isImplicit = roleValue && role === undefined;
// If there is no explicit or implicit role, then assume that the element
// can handle the global set of aria-* properties.
// This actually isn't true - should fix in future release.
if (
typeof roleValue !== 'string'
|| roles.get(roleValue) === undefined
) {
return;
}
// Make sure it has no aria-* properties defined outside of its property set.
const {
props: propKeyValues,
} = roles.get(roleValue);
const propertySet = Object.keys(propKeyValues);
const invalidAriaPropsForRole = [...aria.keys()]
.filter(attribute => propertySet.indexOf(attribute) === -1);
node.attributes.forEach((prop) => {
if (prop.type === 'JSXSpreadAttribute') {
return;
}
const name = propName(prop);
if (invalidAriaPropsForRole.indexOf(name) > -1) {
context.report({
node,
message: errorMessage(name, roleValue, type, isImplicit),
});
}
});
},
}),
};

View File

@@ -0,0 +1,48 @@
/**
* @fileoverview Enforce scope prop is only used on <th> elements.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { dom } from 'aria-query';
import { propName, elementType } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const errorMessage = 'The scope prop can only be used on <th> elements.';
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXAttribute: (node) => {
const name = propName(node);
if (name && name.toUpperCase() !== 'SCOPE') {
return;
}
const { parent } = node;
const tagName = elementType(parent);
// Do not test higher level JSX components, as we do not know what
// low-level DOM element this maps to.
if (!dom.has(tagName)) {
return;
} else if (tagName && tagName.toUpperCase() === 'TH') {
return;
}
context.report({
node,
message: errorMessage,
});
},
}),
};

View File

@@ -0,0 +1,46 @@
/**
* @fileoverview Enforce tabIndex value is not greater than zero.
* @author Ethan Cohen
*/
// ----------------------------------------------------------------------------
// Rule Definition
// ----------------------------------------------------------------------------
import { getLiteralPropValue, propName } from 'jsx-ast-utils';
import { generateObjSchema } from '../util/schemas';
const errorMessage = 'Avoid positive integer values for tabIndex.';
const schema = generateObjSchema();
module.exports = {
meta: {
docs: {},
schema: [schema],
},
create: context => ({
JSXAttribute: (attribute) => {
const name = propName(attribute);
const normalizedName = name ? name.toUpperCase() : '';
// Check if tabIndex is the attribute
if (normalizedName !== 'TABINDEX') {
return;
}
// Only check literals because we can't infer values from certain expressions.
const value = Number(getLiteralPropValue(attribute));
if (isNaN(value) || value <= 0) {
return;
}
context.report({
node: attribute,
message: errorMessage,
});
},
}),
};