UNPKG

@jsxtools/eslint-plugin-jsx-a11y

Version:

Static AST checker for accessibility rules on JSX elements for flat ESLint Config.

192 lines (189 loc) 6.8 kB
import { getProp, getPropValue, getLiteralPropValue } from '../util/module/jsx-ast-utils.js'; import { generateObjSchema, arraySchema } from '../util/schemas.js'; import getElementType from '../util/getElementType.js'; import hasAccessibleChild from '../util/hasAccessibleChild.js'; import isPresentationRole from '../util/isPresentationRole.js'; 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 ariaLabelHasValue = (prop) => { const value = getPropValue(prop); if (value === void 0) { return false; } if (typeof value === "string" && value.length === 0) { return false; } return true; }; const ruleByElement = { img(context, node, nodeType) { const altProp = getProp(node.attributes, "alt"); if (altProp === void 0) { 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; } const ariaLabelProp = getProp(node.attributes, "aria-label"); if (ariaLabelProp !== void 0) { if (!ariaLabelHasValue(ariaLabelProp)) { context.report({ node, message: "The aria-label attribute must have a value. The alt attribute is preferred over aria-label for images." }); } return; } const ariaLabelledbyProp = getProp(node.attributes, "aria-labelledby"); if (ariaLabelledbyProp !== void 0) { if (!ariaLabelHasValue(ariaLabelledbyProp)) { context.report({ node, message: "The aria-labelledby attribute must have a value. The alt attribute is preferred over aria-labelledby for images." }); } return; } context.report({ node, message: `${nodeType} elements must have an alt prop, either with meaningful text, or an empty string for decorative images.` }); return; } const altValue = getPropValue(altProp); const isNullValued = altProp.value === null; if (altValue && !isNullValued || altValue === "") { return; } context.report({ node, message: `Invalid alt value for ${nodeType}. Use alt="" for presentational images.` }); }, object(context, node, unusedNodeType, elementType) { const ariaLabelProp = getProp(node.attributes, "aria-label"); const arialLabelledByProp = getProp(node.attributes, "aria-labelledby"); const hasLabel = ariaLabelHasValue(ariaLabelProp) || ariaLabelHasValue(arialLabelledByProp); const titleProp = getLiteralPropValue(getProp(node.attributes, "title")); const hasTitleAttr = !!titleProp; if (hasLabel || hasTitleAttr || hasAccessibleChild(node.parent, elementType)) { 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 ariaLabelProp = getProp(node.attributes, "aria-label"); const arialLabelledByProp = getProp(node.attributes, "aria-labelledby"); const hasLabel = ariaLabelHasValue(ariaLabelProp) || ariaLabelHasValue(arialLabelledByProp); if (hasLabel) { return; } const altProp = getProp(node.attributes, "alt"); if (altProp === void 0) { 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; 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, nodeType) { if (nodeType === "input") { const typePropValue = getPropValue(getProp(node.attributes, "type")); if (typePropValue !== "image") { return; } } const ariaLabelProp = getProp(node.attributes, "aria-label"); const arialLabelledByProp = getProp(node.attributes, "aria-labelledby"); const hasLabel = ariaLabelHasValue(ariaLabelProp) || ariaLabelHasValue(arialLabelledByProp); if (hasLabel) { return; } const altProp = getProp(node.attributes, "alt"); if (altProp === void 0) { 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; 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.' }); } }; const ruleOfAltText = { meta: { docs: { url: "https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/tree/HEAD/docs/rules/alt-text.md", description: "Enforce all elements that require alternative text have meaningful information to relay back to end user." }, schema: [schema] }, create: (context) => { const options = context.options[0] || {}; const elementOptions = options.elements || DEFAULT_ELEMENTS; const customComponents = elementOptions.flatMap( (element) => options[element] ); const typesToValidate = new Set( [].concat( customComponents, elementOptions ).map((type) => type === 'input[type="image"]' ? "input" : type) ); const elementType = getElementType(context); return { JSXOpeningElement(node) { const nodeType = elementType(node); if (!typesToValidate.has(nodeType)) { return; } let DOMElement = nodeType; if (DOMElement === "input") { DOMElement = 'input[type="image"]'; } if (elementOptions.indexOf(DOMElement) === -1) { DOMElement = elementOptions.find((element) => { const customComponentsForElement = options[element] || []; return customComponentsForElement.indexOf(nodeType) > -1; }); } ruleByElement[DOMElement](context, node, nodeType, elementType); } }; } }; export { ruleOfAltText as default };