diff --git a/README.md b/README.md index aecb184..23c6a89 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ Any use of third-party trademarks or logos are subject to those third-party's po | [dialogbody-needs-title-content-and-actions](docs/rules/dialogbody-needs-title-content-and-actions.md) | A DialogBody should have a header(DialogTitle), content(DialogContent), and footer(DialogActions) | ✅ | | | | [dialogsurface-needs-aria](docs/rules/dialogsurface-needs-aria.md) | DialogueSurface need accessible labelling: aria-describedby on DialogueSurface and aria-label or aria-labelledby(if DialogueTitle is missing) | ✅ | | | | [dropdown-needs-labelling](docs/rules/dropdown-needs-labelling.md) | Accessibility: Dropdown menu must have an id and it needs to be linked via htmlFor of a Label | ✅ | | | -| [field-needs-labelling](docs/rules/field-needs-labelling.md) | Accessibility: Field must have either label, validationMessage and hint attributes | ✅ | | | +| [field-needs-labelling](docs/rules/field-needs-labelling.md) | Accessibility: Field must have label | ✅ | | | | [image-button-missing-aria](docs/rules/image-button-missing-aria.md) | Accessibility: Image buttons must have accessible labelling: title, aria-label, aria-labelledby, aria-describedby | ✅ | | | | [input-components-require-accessible-name](docs/rules/input-components-require-accessible-name.md) | Accessibility: Input fields must have accessible labelling: aria-label, aria-labelledby or an associated label | ✅ | | | | [link-missing-labelling](docs/rules/link-missing-labelling.md) | Accessibility: Image links must have an accessible name. Add either text content, labelling to the image or labelling to the link itself. | ✅ | | 🔧 | @@ -141,6 +141,6 @@ Any use of third-party trademarks or logos are subject to those third-party's po | [tablist-and-tabs-need-labelling](docs/rules/tablist-and-tabs-need-labelling.md) | This rule aims to ensure that Tabs with icons but no text labels have an accessible name and that Tablist is properly labeled. | ✅ | | | | [toolbar-missing-aria](docs/rules/toolbar-missing-aria.md) | Accessibility: Toolbars need accessible labelling: aria-label or aria-labelledby | ✅ | | | | [tooltip-not-recommended](docs/rules/tooltip-not-recommended.md) | Accessibility: Prefer text content or aria over a tooltip for these components MenuItem, SpinButton | ✅ | | | -| [visual-label-better-than-aria-suggestion](docs/rules/visual-label-better-than-aria-suggestion.md) | Visual label is better than an aria-label | | ✅ | | +| [visual-label-better-than-aria-suggestion](docs/rules/visual-label-better-than-aria-suggestion.md) | Visual label is better than an aria-label because sighted users can't read the aria-label text. | | ✅ | | diff --git a/docs/rules/field-needs-labelling.md b/docs/rules/field-needs-labelling.md index 37fb792..37332ae 100644 --- a/docs/rules/field-needs-labelling.md +++ b/docs/rules/field-needs-labelling.md @@ -1,10 +1,10 @@ -# Accessibility: Field must have either label, validationMessage and hint attributes (`@microsoft/fluentui-jsx-a11y/field-needs-labelling`) +# Accessibility: Field must have label (`@microsoft/fluentui-jsx-a11y/field-needs-labelling`) 💼 This rule is enabled in the ✅ `recommended` config. -Field must have `label` prop and either `validationMessage` or `hint` prop. +Field must have `label` prop. @@ -12,7 +12,6 @@ Field must have `label` prop and either `validationMessage` or `hint` prop. - Make sure that Field component has following props: - `label` - - `validationMessage` or `hint` ## Rule Details @@ -21,19 +20,13 @@ This rule aims to make Field component accessible. Examples of **incorrect** code for this rule: ```jsx - + ``` ```jsx - + ``` @@ -41,21 +34,19 @@ Examples of **incorrect** code for this rule: Examples of **correct** code for this rule: ```jsx - + + + +``` + +```jsx + ``` ```jsx - + ``` diff --git a/docs/rules/visual-label-better-than-aria-suggestion.md b/docs/rules/visual-label-better-than-aria-suggestion.md index 7dff8c6..6cdd715 100644 --- a/docs/rules/visual-label-better-than-aria-suggestion.md +++ b/docs/rules/visual-label-better-than-aria-suggestion.md @@ -1,4 +1,4 @@ -# Visual label is better than an aria-label (`@microsoft/fluentui-jsx-a11y/visual-label-better-than-aria-suggestion`) +# Visual label is better than an aria-label because sighted users can't read the aria-label text (`@microsoft/fluentui-jsx-a11y/visual-label-better-than-aria-suggestion`) ⚠️ This rule _warns_ in the ✅ `recommended` config. diff --git a/lib/applicableComponents/labelBasedComponents.ts b/lib/applicableComponents/labelBasedComponents.ts new file mode 100644 index 0000000..4f847a8 --- /dev/null +++ b/lib/applicableComponents/labelBasedComponents.ts @@ -0,0 +1,4 @@ +const labelBasedComponents = ["Label", "label"]; +const elementsUsedAsLabels = ["div", "span", "p", "h1", "h2", "h3", "h4", "h5", "h6"]; + +export { labelBasedComponents, elementsUsedAsLabels }; diff --git a/lib/rules/field-needs-labelling.js b/lib/rules/field-needs-labelling.ts similarity index 73% rename from lib/rules/field-needs-labelling.js rename to lib/rules/field-needs-labelling.ts index 4db33e4..cc0536b 100644 --- a/lib/rules/field-needs-labelling.js +++ b/lib/rules/field-needs-labelling.ts @@ -5,41 +5,41 @@ const { hasNonEmptyProp } = require("../util/hasNonEmptyProp"); const elementType = require("jsx-ast-utils").elementType; +import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ -module.exports = { +const rule = ESLintUtils.RuleCreator.withoutDocs({ + defaultOptions: [], meta: { // possible error messages for the rule messages: { - noUnlabelledField: "Accessibility: Field must have either label, validationMessage and hint attributes" + noUnlabelledField: "Accessibility: Field must have label" }, // "problem" means the rule is identifying code that either will cause an error or may cause a confusing behavior: https://eslint.org/docs/latest/developer-guide/working-with-rules type: "problem", // docs for the rule docs: { - description: "Accessibility: Field must have either label, validationMessage and hint attributes", - recommended: true, + description: "Accessibility: Field must have label", + recommended: "strict", url: "https://www.w3.org/TR/html-aria/" // URL to the documentation page for this rule }, schema: [] }, + // create (function) returns an object with methods that ESLint calls to “visit” nodes while traversing the abstract syntax tree create(context) { return { // visitor functions for different types of nodes - JSXOpeningElement(node) { + JSXOpeningElement(node: TSESTree.JSXOpeningElement) { // if it is not a Spinner, return if (elementType(node) !== "Field") { return; } - if ( - hasNonEmptyProp(node.attributes, "label", true) && - (hasNonEmptyProp(node.attributes, "validationMessage", true) || hasNonEmptyProp(node.attributes, "hint", true)) - ) { + if (hasNonEmptyProp(node.attributes, "label")) { return; } @@ -51,5 +51,6 @@ module.exports = { } }; } -}; +}); +export default rule; diff --git a/lib/rules/input-components-require-accessible-name.ts b/lib/rules/input-components-require-accessible-name.ts index 9fa0c88..0a214d0 100644 --- a/lib/rules/input-components-require-accessible-name.ts +++ b/lib/rules/input-components-require-accessible-name.ts @@ -7,6 +7,7 @@ import { isInsideLabelTag, hasAssociatedLabelViaHtmlFor, hasAssociatedLabelViaAr import { hasFieldParent } from "../util/hasFieldParent"; import { applicableComponents } from "../applicableComponents/inputBasedComponents"; import { JSXOpeningElement } from "estree-jsx"; +import { hasNonEmptyProp } from "../util/hasNonEmptyProp"; //------------------------------------------------------------------------------ // Rule Definition @@ -17,7 +18,7 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({ meta: { // possible error messages for the rule messages: { - missingLabelOnInput: `Accessibility - input fields must have a aria label associated with it: ${applicableComponents.join( + missingLabelOnInput: `Accessibility - input fields must have an accessible label associated with it: ${applicableComponents.join( ", " )}` }, @@ -43,6 +44,7 @@ const rule = ESLintUtils.RuleCreator.withoutDocs({ // wrapped in Label tag, labelled with htmlFor, labelled with aria-labelledby if ( + hasNonEmptyProp(node.attributes, "aria-label") || hasFieldParent(context) || isInsideLabelTag(context) || hasAssociatedLabelViaHtmlFor(node, context) || diff --git a/lib/rules/tablist-and-tabs-need-labelling.js b/lib/rules/tablist-and-tabs-need-labelling.js deleted file mode 100644 index aef89ba..0000000 --- a/lib/rules/tablist-and-tabs-need-labelling.js +++ /dev/null @@ -1,76 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -"use strict"; - -//------------------------------------------------------------------------------ -// Rule Definition -//------------------------------------------------------------------------------ - -/** @type {import('eslint').Rule.RuleModule} */ -module.exports = { - meta: { - type: 'problem', - docs: { - description: 'This rule aims to ensure that Tabs with icons but no text labels have an accessible name and that Tablist is properly labeled.', - recommended: true, - url: 'https://www.w3.org/WAI/ARIA/apg/patterns/tabs/', // URL to the documentation page for this rule - }, - fixable: null, - schema: [], - messages: { - missingTabLabel: 'Accessibility: Tab elements must have an aria-label attribute is there is no visiable text content', - missingTablistLabel: 'Accessibility: Tablist must have an accessible label' - }, - }, - - create(context) { - const { hasTextContentChild } = require('../util/hasTextContentChild'); - const { hasNonEmptyProp } = require('../util/hasNonEmptyProp'); - const { hasAssociatedLabelViaAriaLabelledBy } = require('../util/labelUtils'); - - var elementType = require("jsx-ast-utils").elementType; - - return { - - // visitor functions for different types of nodes - JSXOpeningElement(node) { - const elementTypeValue = elementType(node); - - // if it is not a Tablist or Tab, return - if (elementTypeValue !== 'Tablist' && elementTypeValue !== 'Tab') { - return; - } - - // Check for Tablist elements - if (elementTypeValue === "Tablist") { - if ( - // if the Tablist has a label, if the Tablist has an associated label, return - hasNonEmptyProp(node.attributes, 'aria-label') || //aria-label - hasAssociatedLabelViaAriaLabelledBy(node, context) // aria-labelledby - ) { - return; - } - context.report({ - node, - messageId: 'missingTablistLabel' - }); - } - - // Check for Tab elements - if (elementTypeValue === 'Tab') { - if ( - hasTextContentChild(node.parent) || // text content - hasNonEmptyProp(node.attributes, 'aria-label') // aria-label - ) { - return; - } - context.report({ - node, - messageId: 'missingTabLabel' - }); - } - } - }; - }, -}; diff --git a/lib/rules/tablist-and-tabs-need-labelling.ts b/lib/rules/tablist-and-tabs-need-labelling.ts new file mode 100644 index 0000000..3545fc0 --- /dev/null +++ b/lib/rules/tablist-and-tabs-need-labelling.ts @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; +import { hasTextContentChild } from "../util/hasTextContentChild"; +import { hasNonEmptyProp } from "../util/hasNonEmptyProp"; +import { hasAssociatedLabelViaAriaLabelledBy } from "../util/labelUtils"; +import { elementType } from "jsx-ast-utils"; +import { JSXOpeningElement } from "estree-jsx"; + +//------------------------------------------------------------------------------ +// Rule Definition +//------------------------------------------------------------------------------ + +const rule = ESLintUtils.RuleCreator.withoutDocs({ + defaultOptions: [], + meta: { + type: "problem", + docs: { + description: + "This rule aims to ensure that Tabs with icons but no text labels have an accessible name and that Tablist is properly labeled.", + recommended: "strict", + url: "https://www.w3.org/WAI/ARIA/apg/patterns/tabs/" // URL to the documentation page for this rule + }, + fixable: undefined, + schema: [], + messages: { + missingTabLabel: "Accessibility: Tab elements must have an aria-label attribute is there is no visiable text content", + missingTablistLabel: "Accessibility: Tablist must have an accessible label" + } + }, + + create(context) { + return { + // visitor functions for different types of nodes + JSXOpeningElement(node: TSESTree.JSXOpeningElement) { + const elementTypeValue = elementType(node as unknown as JSXOpeningElement); + + // if it is not a Tablist or Tab, return + if (elementTypeValue !== "Tablist" && elementTypeValue !== "Tab") { + return; + } + + // Check for Tablist elements + if (elementTypeValue === "Tablist") { + if ( + // if the Tablist has a label, if the Tablist has an associated label, return + hasNonEmptyProp(node.attributes, "aria-label") || //aria-label + hasAssociatedLabelViaAriaLabelledBy(node, context) // aria-labelledby + ) { + return; + } + context.report({ + node, + messageId: "missingTablistLabel" + }); + } + + // Check for Tab elements + if (elementTypeValue === "Tab") { + if ( + hasTextContentChild(node.parent as unknown as TSESTree.JSXElement) || // text content + hasNonEmptyProp(node.attributes, "aria-label") // aria-label + ) { + return; + } + context.report({ + node, + messageId: "missingTabLabel" + }); + } + } + }; + } +}); + +export default rule; diff --git a/lib/rules/visual-label-better-than-aria-suggestion.js b/lib/rules/visual-label-better-than-aria-suggestion.ts similarity index 65% rename from lib/rules/visual-label-better-than-aria-suggestion.js rename to lib/rules/visual-label-better-than-aria-suggestion.ts index 1d167e7..c2226ec 100644 --- a/lib/rules/visual-label-better-than-aria-suggestion.js +++ b/lib/rules/visual-label-better-than-aria-suggestion.ts @@ -1,18 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -"use strict"; - -var elementType = require("jsx-ast-utils").elementType; -const { hasNonEmptyProp } = require("../util/hasNonEmptyProp"); -const { applicableComponents } = require("../applicableComponents/buttonBasedComponents"); +import { hasNonEmptyProp } from "../util/hasNonEmptyProp"; +import { applicableComponents } from "../applicableComponents/inputBasedComponents"; +import { ESLintUtils, TSESTree } from "@typescript-eslint/utils"; +import { elementType } from "jsx-ast-utils"; +import { JSXOpeningElement } from "estree-jsx"; //------------------------------------------------------------------------------ // Rule Definition //------------------------------------------------------------------------------ -/** @type {import('eslint').Rule.RuleModule} */ -module.exports = { +const rule = ESLintUtils.RuleCreator.withoutDocs({ + defaultOptions: [], meta: { // possible warning messages for the lint rule messages: { @@ -20,20 +20,21 @@ module.exports = { }, type: "suggestion", // `problem`, `suggestion`, or `layout` docs: { - description: "Visual label is better than an aria-label", - recommended: true, - url: null // URL to the documentation page for this rule + description: "Visual label is better than an aria-label because sighted users can't read the aria-label text.", + recommended: "strict", + url: undefined // URL to the documentation page for this rule }, - fixable: null, // Or `code` or `whitespace` + fixable: undefined, // Or `code` or `whitespace` schema: [] // Add a schema if the rule has options }, + // create (function) returns an object with methods that ESLint calls to “visit” nodes while traversing the abstract syntax tree create(context) { return { // visitor functions for different types of nodes - JSXOpeningElement(node) { + JSXOpeningElement(node: TSESTree.JSXOpeningElement) { // if it is not a listed component, return - if (!applicableComponents.includes(elementType(node))) { + if (!applicableComponents.includes(elementType(node as unknown as JSXOpeningElement))) { return; } @@ -47,4 +48,6 @@ module.exports = { } }; } -}; +}); + +export default rule; diff --git a/lib/util/flattenChildren.js b/lib/util/flattenChildren.js deleted file mode 100644 index d8ff7f3..0000000 --- a/lib/util/flattenChildren.js +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -// TODO: add comments -function flattenChildren(node) { - const flatChildren = []; - - if (node.children && node.children.length > 0) { - node.children.forEach(child => { - if (child.type === 'JSXElement' && child.children && child.children.length > 0) { - flatChildren.push(child, ...flattenChildren(child)); - } else { - flatChildren.push(child); - } - }); - } - - return flatChildren; -} - -module.exports.flattenChildren = flattenChildren; diff --git a/lib/util/flattenChildren.ts b/lib/util/flattenChildren.ts new file mode 100644 index 0000000..c8b285a --- /dev/null +++ b/lib/util/flattenChildren.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TSESTree } from "@typescript-eslint/types"; + +// Flatten the JSX tree structure by recursively collecting all child elements +const flattenChildren = (node: TSESTree.JSXElement): TSESTree.JSXElement[] => { + const flatChildren: TSESTree.JSXElement[] = []; + + if (node.children && node.children.length > 0) { + node.children.forEach(child => { + if (child.type === "JSXElement") { + const jsxChild = child as TSESTree.JSXElement; + flatChildren.push(jsxChild, ...flattenChildren(jsxChild)); + } + }); + } + + return flatChildren; +}; + +export { flattenChildren }; diff --git a/lib/util/hasFieldParent.js b/lib/util/hasFieldParent.js deleted file mode 100644 index 223d1e4..0000000 --- a/lib/util/hasFieldParent.js +++ /dev/null @@ -1,31 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -var elementType = require("jsx-ast-utils").elementType; - -function hasFieldParent(context) { - const ancestors = context.getAncestors(); - - if (ancestors == null || ancestors.length === 0) { - return false; - } - - var field = false; - - ancestors.forEach(item => { - if ( - item.type === "JSXElement" && - item.openingElement != null && - item.openingElement.type === "JSXOpeningElement" && - elementType(item.openingElement) === "Field" - ) { - field = true; - } - }); - - return field; -} - -module.exports = { - hasFieldParent -}; diff --git a/lib/util/hasFieldParent.ts b/lib/util/hasFieldParent.ts new file mode 100644 index 0000000..dd4605c --- /dev/null +++ b/lib/util/hasFieldParent.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TSESTree } from "@typescript-eslint/types"; +import { elementType } from "jsx-ast-utils"; +import { TSESLint } from "@typescript-eslint/utils"; +import { JSXOpeningElement } from "estree-jsx"; + +// Function to check if the current node has a "Field" parent JSXElement +export const hasFieldParent = (context: TSESLint.RuleContext): boolean => { + const ancestors: TSESTree.Node[] = context.getAncestors(); + + if (ancestors == null || ancestors.length === 0) { + return false; + } + + let field = false; + + ancestors.forEach(item => { + if ( + item.type === "JSXElement" && + item.openingElement != null && + item.openingElement.type === "JSXOpeningElement" && + elementType(item.openingElement as unknown as JSXOpeningElement) === "Field" + ) { + field = true; + } + }); + + return field; +}; diff --git a/lib/util/hasLabelledChildImage.js b/lib/util/hasLabelledChildImage.js deleted file mode 100644 index 9b5684d..0000000 --- a/lib/util/hasLabelledChildImage.js +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -const { flattenChildren } = require("./flattenChildren"); -const { hasProp, getPropValue } = require("jsx-ast-utils"); -const { hasNonEmptyProp } = require("./hasNonEmptyProp"); -const { fluentImageComponents, imageDomNodes } = require("../applicableComponents/imageBasedComponents"); - -const mergedImageComponents = [...fluentImageComponents, ...imageDomNodes]; - -/** - * hasLabelledChildImage - determines if a component has text content as a child e.g. abc - * @param {*} node JSXElement - * @returns boolean - */ -function hasLabelledChildImage(node) { - // no children - if (node.children == null || node.children == undefined || node.children.length === 0) { - return false; - } - - // Check if there is an accessible image - const hasAccessibleImage = flattenChildren(node).some(child => { - if (child.type === "JSXElement" && mergedImageComponents.includes(child.openingElement.name.name)) { - return hasProp(child.openingElement.attributes, "aria-hidden") || getPropValue(child.openingElement.attributes, "alt") - ? false - : hasNonEmptyProp(child.openingElement.attributes, "title") || - hasNonEmptyProp(child.openingElement.attributes, "alt") || - hasNonEmptyProp(child.openingElement.attributes, "aria-label") || - hasNonEmptyProp(child.openingElement.attributes, "aria-labelledby"); - } - return false; - }); - - return hasAccessibleImage; -} - -module.exports = { - hasLabelledChildImage -}; diff --git a/lib/util/hasLabelledChildImage.ts b/lib/util/hasLabelledChildImage.ts new file mode 100644 index 0000000..443941c --- /dev/null +++ b/lib/util/hasLabelledChildImage.ts @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { flattenChildren } from "./flattenChildren"; +import { TSESTree } from "@typescript-eslint/types"; +import { hasProp, getPropValue, getProp } from "jsx-ast-utils"; +import { hasNonEmptyProp } from "./hasNonEmptyProp"; +import { fluentImageComponents, imageDomNodes } from "../applicableComponents/imageBasedComponents"; +import { JSXOpeningElement } from "estree-jsx"; + +const mergedImageComponents = [...fluentImageComponents, ...imageDomNodes]; + +/** + * Checks if a JSX element name is a JSXIdentifier and matches a component name. + * @param name JSXTagNameExpression + * @returns boolean + */ +const isJSXIdentifierWithName = (name: TSESTree.JSXTagNameExpression, validNames: string[]): boolean => { + return name.type === "JSXIdentifier" && validNames.includes(name.name); +}; + +/** + * Determines if a component has an accessible image as a child. + * @param {*} node JSXElement + * @returns boolean + */ +const hasLabelledChildImage = (node: TSESTree.JSXElement): boolean => { + console.log("node::", node); + if (!node.children || node.children.length === 0) { + return false; + } + + return flattenChildren(node).some(child => { + if (child.type === "JSXElement" && isJSXIdentifierWithName(child.openingElement.name, mergedImageComponents)) { + const attributes = child.openingElement.attributes; + console.log("attributes::", attributes); + console.log("hasAccessibilityAttributes(attributes)", hasAccessibilityAttributes(attributes)); + console.log("!isImageHidden(attributes)", !isImageHidden(attributes)); + + return !isImageHidden(attributes) && hasAccessibilityAttributes(attributes); + } + return false; + }); +}; + +/** + * Checks if an image element has any of the attributes indicating it is accessible. + * @param {*} attributes JSX attributes of the image element + * @returns boolean + */ +const hasAccessibilityAttributes = (attributes: TSESTree.JSXOpeningElement["attributes"]): boolean => { + return ( + hasNonEmptyProp(attributes, "title") || + hasNonEmptyProp(attributes, "alt") || + hasNonEmptyProp(attributes, "aria-label") || + hasNonEmptyProp(attributes, "aria-labelledby") + ); +}; + +/** + * Checks if an image element is marked as hidden using `aria-hidden` or has an empty `alt` attribute. + * @param {*} attributes JSX attributes of the image element + * @returns boolean + */ +const isImageHidden = (attributes: TSESTree.JSXOpeningElement["attributes"]): boolean => { + // Check if the image has the `aria-hidden` attribute + if (hasProp(attributes as unknown as JSXOpeningElement["attributes"], "aria-hidden")) { + return true; + } + + // Check if the image has an `aria-label` attribute with a non-empty value + const ariaLabelProp = getProp(attributes as unknown as JSXOpeningElement["attributes"], "aria-label"); + if (ariaLabelProp) { + const ariaLabelValue = getPropValue(ariaLabelProp); + if (ariaLabelValue) { + return false; // If `aria-label` is present and has a value, the image is not hidden + } + } + + // Check if the image has an `alt` attribute and return true if the `alt` value is falsy + const altProp = getProp(attributes as unknown as JSXOpeningElement["attributes"], "alt"); + if (altProp) { + const altValue = getPropValue(altProp); + return !altValue; // Returns true if `altValue` is falsy (e.g., empty string, null, or undefined) + } + + return true; // If neither `alt` nor `aria-label` is present, consider the image hidden +}; + +export { hasLabelledChildImage, isImageHidden, hasAccessibilityAttributes, isJSXIdentifierWithName }; diff --git a/lib/util/hasNonEmptyProp.js b/lib/util/hasNonEmptyProp.js deleted file mode 100644 index 2d35771..0000000 --- a/lib/util/hasNonEmptyProp.js +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -var hasProp = require("jsx-ast-utils").hasProp; -var getPropValue = require("jsx-ast-utils").getPropValue; -var getProp = require("jsx-ast-utils").getProp; - -/** - * Determines if the prop exists and has a non-empty value. - * @param {*} attributes - * @param {*} name - * @returns boolean - */ -function hasNonEmptyProp(attributes, name) { - if (!hasProp(attributes, name)) { - return false; - } - - const propValue = getPropValue(getProp(attributes, name)); - - /** - * getPropValue internally normalizes "true", "false" to boolean values. - * So it is sufficent to check if the prop exists and return. - */ - if (typeof propValue === "boolean" || typeof propValue === "number") { - return true; - } - - return propValue.trim().length > 0; -} - -module.exports.hasNonEmptyProp = hasNonEmptyProp; - diff --git a/lib/util/hasNonEmptyProp.ts b/lib/util/hasNonEmptyProp.ts new file mode 100644 index 0000000..83a2fb7 --- /dev/null +++ b/lib/util/hasNonEmptyProp.ts @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { TSESTree } from "@typescript-eslint/utils"; +import { JSXOpeningElement } from "estree-jsx"; +import { hasProp, getPropValue, getProp } from "jsx-ast-utils"; + +/** + * Determines if the prop exists and has a non-empty value. + * @param {*} attributes + * @param {*} name + * @returns boolean + */ +const hasNonEmptyProp = (attributes: TSESTree.JSXOpeningElement["attributes"], name: string): boolean => { + if (!hasProp(attributes as unknown as JSXOpeningElement["attributes"], name)) { + return false; + } + + const prop = getProp(attributes as unknown as JSXOpeningElement["attributes"], name); + + // Safely get the value of the prop, handling potential undefined or null values + const propValue = prop ? getPropValue(prop) : undefined; + + // Check for various types that `getPropValue` could return + if (propValue === null || propValue === undefined) { + return false; + } + + if (typeof propValue === "boolean" || typeof propValue === "number") { + // Booleans and numbers are considered non-empty if they exist + return true; + } + + if (typeof propValue === "string") { + // For strings, check if it is non-empty + return propValue.trim().length > 0; + } + + // Handle other potential types (e.g., arrays, objects) + if (Array.isArray(propValue)) { + return propValue.length > 0; + } + + if (typeof propValue === "object") { + // Objects are considered non-empty if they have properties + return Object.keys(propValue).length > 0; + } + + // If the type is not handled, return false as a fallback + return false; +}; + +export { hasNonEmptyProp }; diff --git a/lib/util/hasTextContentChild.js b/lib/util/hasTextContentChild.ts similarity index 74% rename from lib/util/hasTextContentChild.js rename to lib/util/hasTextContentChild.ts index 70299ed..5f68401 100644 --- a/lib/util/hasTextContentChild.js +++ b/lib/util/hasTextContentChild.ts @@ -1,13 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { TSESTree } from "@typescript-eslint/types"; + /** * hasTextContentChild - determines if a component has text content as a child e.g. * @param {*} node JSXElement * @returns boolean */ -function hasTextContentChild(node) { +const hasTextContentChild = (node?: TSESTree.JSXElement) => { // no children + if (!node) { + return false; + } + if (node.children == null || node.children == undefined || node.children.length === 0) { return false; } @@ -17,8 +23,6 @@ function hasTextContentChild(node) { }); return result.length !== 0; -} - -module.exports = { - hasTextContentChild }; + +export { hasTextContentChild }; diff --git a/lib/util/labelUtils.js b/lib/util/labelUtils.js deleted file mode 100644 index f3179b6..0000000 --- a/lib/util/labelUtils.js +++ /dev/null @@ -1,153 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -var elementType = require("jsx-ast-utils").elementType; -var getPropValue = require("jsx-ast-utils").getPropValue; -var getProp = require("jsx-ast-utils").getProp; -const { hasNonEmptyProp } = require("./hasNonEmptyProp"); - -/** - * Checks if the element is nested within a Label tag. - * e.g. - * - * @param {*} context - * @returns - */ -function isInsideLabelTag(context) { - return context - .getAncestors() - .some( - node => - node.type === "JSXElement" && (elementType(node.openingElement) === "Label" || elementType(node.openingElement) === "label") - ); -} - -/** - * Checks if there is a Label component inside the source code with a htmlFor attribute matching that of the id parameter. - * e.g. - * id=parameter, - * @param {*} idValue - * @param {*} context - * @returns boolean for match found or not. - */ -function hasLabelWithHtmlForId(idValue, context) { - if (idValue === "") { - return false; - } - const sourceCode = context.getSourceCode(); - const regex = /]*htmlFor[^>]*=[^>]*[{"|{'|"|']([^>'"}]*)['|"|'}|"}][^>]*>/gim; - const matches = regex.exec(sourceCode.text); - return !!matches && matches.some(match => match === idValue); -} - -/** - * Checks if there is a Label component inside the source code with an id matching that of the id parameter. - * e.g. - * id=parameter, - * @param {*} idValue value of the props id e.g. - * - * - * @param {*} openingElement - * @param {*} context - * @param {*} ariaAttribute - * @returns boolean for match found or not. - */ -function hasAssociatedAriaText(openingElement, context, ariaAttribute) { - const hasAssociatedAriaText = hasNonEmptyProp(openingElement.attributes, ariaAttribute); - const idValue = getPropValue(getProp(openingElement.attributes, ariaAttribute)); - let hasHtmlId = false; - if (idValue) { - const sourceCode = context.getSourceCode(); - - const regex = /<(\w+)[^>]*id\s*=\s*["']([^"']*)["'][^>]*>/gi; - let match; - const ids = []; - - while ((match = regex.exec(sourceCode.text)) !== null) { - ids.push(match[2]); - } - hasHtmlId = ids.some(id => id === idValue); - } - - return hasAssociatedAriaText && hasHtmlId; -} - -module.exports = { - isInsideLabelTag, - hasLabelWithHtmlForId, - hasLabelWithHtmlId, - hasAssociatedLabelViaAriaLabelledBy, - hasAssociatedLabelViaHtmlFor, - hasAssociatedLabelViaAriaDescribedby, - hasAssociatedAriaText -}; diff --git a/lib/util/labelUtils.ts b/lib/util/labelUtils.ts new file mode 100644 index 0000000..5575e78 --- /dev/null +++ b/lib/util/labelUtils.ts @@ -0,0 +1,241 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { elementType } from "jsx-ast-utils"; +import { getPropValue } from "jsx-ast-utils"; +import { getProp } from "jsx-ast-utils"; +import { hasNonEmptyProp } from "./hasNonEmptyProp"; +import { TSESLint } from "@typescript-eslint/utils"; // Assuming context comes from TSESLint +import { JSXOpeningElement } from "estree-jsx"; +import { TSESTree } from "@typescript-eslint/utils"; + +/** + * Checks if the element is nested within a Label tag. + * e.g. + * + * @param {*} context + * @returns + */ +const isInsideLabelTag = (context: TSESLint.RuleContext): boolean => { + return context.getAncestors().some(node => { + if (node.type !== "JSXElement") return false; + const tagName = elementType(node.openingElement as unknown as JSXOpeningElement); + return tagName.toLowerCase() === "label"; + }); +}; + +/** + * Checks if there is a Label component inside the source code with a htmlFor attribute matching that of the id parameter. + * e.g. + * id=parameter, + * @param {*} idValue + * @param {*} context + * @returns boolean for match found or not. + */ +const hasLabelWithHtmlForId = (idValue: string, context: TSESLint.RuleContext): boolean => { + if (idValue === "") { + return false; + } + const sourceCode = context.getSourceCode(); + + const regex = /<(Label|label)[^>]*\bhtmlFor\b\s*=\s*["{']([^"'{}]*)["'}]/gi; + + let match; + while ((match = regex.exec(sourceCode.text)) !== null) { + // `match[2]` contains the `htmlFor` attribute value + if (match[2] === idValue) { + return true; + } + } + return false; +}; + +/** + * Checks if there is a Label component inside the source code with an id matching that of the id parameter. + * e.g. + * id=parameter, + * @param {*} idValue value of the props id e.g. + * + * + * @param {*} openingElement + * @param {*} context + * @param {*} ariaAttribute + * @returns boolean for match found or not. + */ +const hasAssociatedAriaText = ( + openingElement: TSESTree.JSXOpeningElement, + context: TSESLint.RuleContext, + ariaAttribute: string +) => { + const hasAssociatedAriaText = hasNonEmptyProp(openingElement.attributes, ariaAttribute); + + const prop = getProp(openingElement.attributes as unknown as JSXOpeningElement["attributes"], ariaAttribute); + + const idValue = prop ? getPropValue(prop) : undefined; + + let hasHtmlId = false; + if (idValue) { + const sourceCode = context.getSourceCode(); + + const regex = /<(\w+)[^>]*id\s*=\s*["']([^"']*)["'][^>]*>/gi; + let match; + const ids = []; + + while ((match = regex.exec(sourceCode.text)) !== null) { + ids.push(match[2]); + } + hasHtmlId = ids.some(id => id === idValue); + } + + return hasAssociatedAriaText && hasHtmlId; +}; + +export { + isInsideLabelTag, + hasLabelWithHtmlForId, + hasLabelWithHtmlId, + hasAssociatedLabelViaAriaLabelledBy, + hasAssociatedLabelViaHtmlFor, + hasAssociatedLabelViaAriaDescribedby, + hasAssociatedAriaText, + hasOtherElementWithHtmlId +}; diff --git a/tests/lib/rules/field-needs-labelling.js b/tests/lib/rules/field-needs-labelling.test.ts similarity index 54% rename from tests/lib/rules/field-needs-labelling.js rename to tests/lib/rules/field-needs-labelling.test.ts index 56fb68e..c407efa 100644 --- a/tests/lib/rules/field-needs-labelling.js +++ b/tests/lib/rules/field-needs-labelling.test.ts @@ -6,22 +6,17 @@ //------------------------------------------------------------------------------ // Requirements //------------------------------------------------------------------------------ - -const rule = require("../../../lib/rules/field-needs-labelling"), - RuleTester = require("eslint").RuleTester; +import { Rule } from "eslint"; +import ruleTester from "./helper/ruleTester"; +import rule from "../../../lib/rules/field-needs-labelling"; //------------------------------------------------------------------------------ // Tests //------------------------------------------------------------------------------ -const ruleTester = new RuleTester(); -ruleTester.run("field-needs-labelling", rule, { +ruleTester.run("field-needs-labelling", rule as unknown as Rule.RuleModule, { valid: [ - ` + ` `, ` - - `, + code: ``, errors: [{ messageId: "noUnlabelledField" }] }, { - code: ` - - `, + code: ``, errors: [{ messageId: "noUnlabelledField" }] } ] }); - diff --git a/tests/lib/rules/input-components-require-accessible-name.test.ts b/tests/lib/rules/input-components-require-accessible-name.test.ts index f29bacb..e1baf33 100644 --- a/tests/lib/rules/input-components-require-accessible-name.test.ts +++ b/tests/lib/rules/input-components-require-accessible-name.test.ts @@ -9,19 +9,36 @@ import { Rule } from "eslint"; import ruleTester from "./helper/ruleTester"; import rule from "../../../lib/rules/input-components-require-accessible-name"; import { applicableComponents } from "../../../lib/applicableComponents/inputBasedComponents"; +import { labelBasedComponents, elementsUsedAsLabels } from "../../../lib/applicableComponents/labelBasedComponents"; //------------------------------------------------------------------------------ // Helper function to generate test cases //------------------------------------------------------------------------------ -function generateTestCases(componentName: string) { +function generateTestCases(labelComponent: string, componentName: string) { return { valid: [ - `<><${componentName} id="some-id"/>`, - `<><${componentName} id="some-id" aria-labelledby="test-span"/>`, - ``, - ``, - ``, - `<${componentName} />` + `<><${labelComponent} id="test-span">Some Label<${componentName} id="some-id" aria-labelledby="test-span"/>` + ], + invalid: [ + { + code: `<><${labelComponent} id="test-span-2">Some Label<${componentName} id="some-id" aria-labelledby="test-span"/>`, + errors: [{ messageId: "missingLabelOnInput" }] + } + ] + }; +} + +function generateTestCasesLabel(labelComponent: string, componentName: string) { + return { + valid: [ + `<><${labelComponent} htmlFor="some-id">Some Label<${componentName} id="some-id"/>`, + `<><${labelComponent} id="test-span">Some Label<${componentName} id="some-id" aria-labelledby="test-span"/>`, + `<${labelComponent}>test`, + `<${labelComponent}>test<${componentName} />`, + `<${labelComponent}>test<${componentName} />`, + `<${componentName} />`, + `<${componentName} aria-label="this is my component" />`, + `<><${labelComponent} id="paragraph_label-2">type here<${componentName} aria-labelledby="paragraph_label-2"><${labelComponent} id="paragraph_label-3">type here<${componentName} aria-labelledby="paragraph_label-3">` ], invalid: [ { @@ -29,11 +46,11 @@ function generateTestCases(componentName: string) { errors: [{ messageId: "missingLabelOnInput" }] }, { - code: `<>