diff --git a/CHANGELOG.md b/CHANGELOG.md index d38bc17c8a..b44cd97f4f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,10 +8,15 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ### Fixed * [`jsx-key`]: avoid a crash with optional chaining ([#3371][] @ljharb) * [`jsx-sort-props`]: avoid a crash with spread props ([#3376][] @ljharb) +* [`no-unknown-property`]: properly recognize valid data- and aria- attributes ([#3377][] @sjarva) +* [`no-unknown-property`]: properly recognize unknown HTML/DOM attributes ([#3377][] @sjarva) ### Changed -* [Docs] [`jsx-sort-propts`]: replace ref string with ref variable ([#3375][] @Luccasoli) +* [Docs] [`jsx-sort-props`]: replace ref string with ref variable ([#3375][] @Luccasoli) +* [Refactor] [`no-unknown-property`]: improve jsdoc; extract logic to separate functions ([#3377][] @sjarva) +* [Refactor] [`no-unknown-property`]: update DOM properties to include also one word properties ([#3377][] @sjarva) +[#3377]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3377 [#3376]: https://github.com/jsx-eslint/eslint-plugin-react/issues/3376 [#3375]: https://github.com/jsx-eslint/eslint-plugin-react/issues/3375 [#3371]: https://github.com/jsx-eslint/eslint-plugin-react/issues/3371 diff --git a/docs/rules/no-unknown-property.md b/docs/rules/no-unknown-property.md index 0a38a1b894..108fa42a44 100644 --- a/docs/rules/no-unknown-property.md +++ b/docs/rules/no-unknown-property.md @@ -4,7 +4,8 @@ 🔧 This rule is automatically fixable using the `--fix` [flag](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix) on the command line. -In JSX all DOM properties and attributes should be camelCased to be consistent with standard JavaScript style. This can be a possible source of error if you are used to writing plain HTML. +In JSX most DOM properties and attributes should be camelCased to be consistent with standard JavaScript style. This can be a possible source of error if you are used to writing plain HTML. +Only `data-*` and `aria-*` attributes are usings hyphens and lowercase letters in JSX. ## Rule Details @@ -14,6 +15,10 @@ Examples of **incorrect** code for this rule: var React = require('react'); var Hello =
Hello World
; +var Alphabet =
Alphabet
; + +// Invalid aria-* attribute +var IconButton =
; ``` Examples of **correct** code for this rule: @@ -22,6 +27,23 @@ Examples of **correct** code for this rule: var React = require('react'); var Hello =
Hello World
; +var Button = ; +var Img = A cat sleeping on a keyboard; + +// aria-* attributes +var IconButton = ; + +// data-* attributes +var Data =
Some data
; + +// React components are ignored +var MyComponent = ; +var AnotherComponent = ; + +// Custom web components are ignored +var MyElem =
; +var AtomPanel = ; + ``` ## Rule Options diff --git a/lib/rules/no-unknown-property.js b/lib/rules/no-unknown-property.js index 09cf54818c..d8f7f1e319 100644 --- a/lib/rules/no-unknown-property.js +++ b/lib/rules/no-unknown-property.js @@ -116,30 +116,75 @@ const SVGDOM_ATTRIBUTE_NAMES = { 'xml:space': 'xmlSpace', }; -const DOM_PROPERTY_NAMES = [ - // Standard - 'acceptCharset', 'accessKey', 'allowFullScreen', 'autoComplete', 'autoFocus', 'autoPlay', - 'cellPadding', 'cellSpacing', 'classID', 'className', 'colSpan', 'contentEditable', 'contextMenu', - 'dateTime', 'encType', 'formAction', 'formEncType', 'formMethod', 'formNoValidate', 'formTarget', - 'frameBorder', 'hrefLang', 'htmlFor', 'httpEquiv', 'inputMode', 'keyParams', 'keyType', 'marginHeight', 'marginWidth', +const DOM_PROPERTY_NAMES_ONE_WORD = [ + // Global attributes - can be used on any HTML/DOM element + // See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes + 'dir', 'draggable', 'hidden', 'id', 'lang', 'nonce', 'part', 'slot', 'style', 'title', 'translate', + // Element specific attributes + // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes (includes global attributes too) + // To be considered if these should be added also to ATTRIBUTE_TAGS_MAP + 'accept', 'action', 'allow', 'alt', 'async', 'buffered', 'capture', 'challenge', 'cite', 'code', 'cols', + 'content', 'coords', 'csp', 'data', 'decoding', 'default', 'defer', 'disabled', 'form', + 'headers', 'height', 'high', 'href', 'icon', 'importance', 'integrity', 'kind', 'label', + 'language', 'loading', 'list', 'loop', 'low', 'max', 'media', 'method', 'min', 'multiple', 'muted', + 'name', 'open', 'optimum', 'pattern', 'ping', 'placeholder', 'poster', 'preload', 'profile', + 'rel', 'required', 'reversed', 'role', 'rows', 'sandbox', 'scope', 'selected', 'shape', 'size', 'sizes', + 'span', 'src', 'start', 'step', 'target', 'type', 'value', 'width', 'wrap', + // React specific attributes + 'ref', +]; + +const DOM_PROPERTY_NAMES_TWO_WORDS = [ + // Global attributes - can be used on any HTML/DOM element + // See https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes + 'accessKey', 'autoCapitalize', 'autoFocus', 'contentEditable', 'enterKeyHint', 'exportParts', + 'inputMode', 'itemID', 'itemRef', 'itemProp', 'itemScope', 'itemType', 'spellCheck', 'tabIndex', + // Element specific attributes + // See https://developer.mozilla.org/en-US/docs/Web/HTML/Attributes (includes global attributes too) + // To be considered if these should be added also to ATTRIBUTE_TAGS_MAP + 'acceptCharset', 'allowFullScreen', 'autoComplete', 'autoPlay', 'cellPadding', 'cellSpacing', 'classID', 'codeBase', + 'colSpan', 'contextMenu', 'dateTime', 'encType', 'formAction', 'formEncType', 'formMethod', 'formNoValidate', 'formTarget', + 'frameBorder', 'hrefLang', 'httpEquiv', 'isMap', 'keyParams', 'keyType', 'marginHeight', 'marginWidth', 'maxLength', 'mediaGroup', 'minLength', 'noValidate', 'onAnimationEnd', 'onAnimationIteration', 'onAnimationStart', 'onBlur', 'onChange', 'onClick', 'onContextMenu', 'onCopy', 'onCompositionEnd', 'onCompositionStart', 'onCompositionUpdate', 'onCut', 'onDoubleClick', 'onDrag', 'onDragEnd', 'onDragEnter', 'onDragExit', 'onDragLeave', 'onError', 'onFocus', 'onInput', 'onKeyDown', 'onKeyPress', 'onKeyUp', 'onLoad', 'onWheel', 'onDragOver', 'onDragStart', 'onDrop', 'onMouseDown', 'onMouseEnter', 'onMouseLeave', 'onMouseMove', 'onMouseOut', 'onMouseOver', - 'onMouseUp', 'onPaste', 'onScroll', 'onSelect', 'onSubmit', 'onTransitionEnd', 'radioGroup', 'readOnly', 'rowSpan', - 'spellCheck', 'srcDoc', 'srcLang', 'srcSet', 'tabIndex', 'useMap', - // Non standard - 'autoCapitalize', 'autoCorrect', - 'autoSave', - 'itemProp', 'itemScope', 'itemType', 'itemRef', 'itemID', + 'onMouseUp', 'onPaste', 'onScroll', 'onSelect', 'onSubmit', 'onTransitionEnd', 'radioGroup', 'readOnly', 'referrerPolicy', + 'rowSpan', 'srcDoc', 'srcLang', 'srcSet', 'useMap', + // Safari/Apple specific, no listing available + 'autoCorrect', // https://stackoverflow.com/questions/47985384/html-autocorrect-for-text-input-is-not-working + 'autoSave', // https://stackoverflow.com/questions/25456396/what-is-autosave-attribute-supposed-to-do-how-do-i-use-it + // React specific attributes https://reactjs.org/docs/dom-elements.html#differences-in-attributes + 'className', 'dangerouslySetInnerHTML', 'defaultValue', 'htmlFor', 'onChange', 'suppressContentEditableWarning', 'suppressHydrationWarning', +]; + +const DOM_PROPERTIES_IGNORE_CASE = ['charset']; + +const ARIA_PROPERTIES = [ + // See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes + // Global attributes + 'aria-atomic', 'aria-braillelabel', 'aria-brailleroledescription', 'aria-busy', 'aria-controls', 'aria-current', + 'aria-describedby', 'aria-description', 'aria-details', + 'aria-disabled', 'aria-dropeffect', 'aria-errormessage', 'aria-flowto', 'aria-grabbed', 'aria-haspopup', + 'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label', 'aria-labelledby', 'aria-live', + 'aria-owns', 'aria-relevant', 'aria-roledescription', + // Widget attributes + 'aria-autocomplete', 'aria-checked', 'aria-expanded', 'aria-level', 'aria-modal', 'aria-multiline', 'aria-multiselectable', + 'aria-orientation', 'aria-placeholder', 'aria-pressed', 'aria-readonly', 'aria-required', 'aria-selected', + 'aria-sort', 'aria-valuemax', 'aria-valuemin', 'aria-valuenow', 'aria-valuetext', + // Relationship attributes + 'aria-activedescendant', 'aria-colcount', 'aria-colindex', 'aria-colindextext', 'aria-colspan', + 'aria-posinset', 'aria-rowcount', 'aria-rowindex', 'aria-rowindextext', 'aria-rowspan', 'aria-setsize', ]; + function getDOMPropertyNames(context) { + const ALL_DOM_PROPERTY_NAMES = DOM_PROPERTY_NAMES_TWO_WORDS.concat(DOM_PROPERTY_NAMES_ONE_WORD); // this was removed in React v16.1+, see https://github.com/facebook/react/pull/10823 if (!testReactVersion(context, '>= 16.1.0')) { - return ['allowTransparency'].concat(DOM_PROPERTY_NAMES); + return ['allowTransparency'].concat(ALL_DOM_PROPERTY_NAMES); } - return DOM_PROPERTY_NAMES; + return ALL_DOM_PROPERTY_NAMES; } // ------------------------------------------------------------------------------ @@ -147,24 +192,74 @@ function getDOMPropertyNames(context) { // ------------------------------------------------------------------------------ /** - * Checks if a node matches the JSX tag convention. This also checks if a node - * is extended as a webcomponent using the attribute "is". - * @param {Object} node - JSX element being tested. + * Checks if a node's parent is a JSX tag that is written with lowercase letters, + * and is not a custom web component. Custom web components have a hyphen in tag name, + * or have an `is="some-elem"` attribute. + * + * Note: does not check if a tag's parent against a list of standard HTML/DOM tags. For example, + * a ``'s child would return `true` because "fake" is written only with lowercase letters + * without a hyphen and does not have a `is="some-elem"` attribute. + * + * @param {Object} childNode - JSX element being tested. * @returns {boolean} Whether or not the node name match the JSX tag convention. */ -const tagConvention = /^[a-z][^-]*$/; -function isTagName(node) { - if (tagConvention.test(node.parent.name.name)) { - // https://www.w3.org/TR/custom-elements/#type-extension-semantics - return !node.parent.attributes.some((attrNode) => ( +function isValidHTMLTagInJSX(childNode) { + const tagConvention = /^[a-z][^-]*$/; + if (tagConvention.test(childNode.parent.name.name)) { + return !childNode.parent.attributes.some((attrNode) => ( attrNode.type === 'JSXAttribute' && attrNode.name.type === 'JSXIdentifier' && attrNode.name.name === 'is' + // To learn more about custom web components and `is` attribute, + // see https://html.spec.whatwg.org/multipage/custom-elements.html#custom-elements-customized-builtin-example + )); } return false; } +/** + * Checks if an attribute name is a valid `data-*` attribute: + * if the name starts with "data-" and has some lowcase (a to z) words, separated but hyphens (-) + * (which is also called "kebab case" or "dash case"), then the attribute is valid data attribute. + * + * @param {String} name - Attribute name to be tested + * @returns {boolean} Result + */ +function isValidDataAttribute(name) { + const dataAttrConvention = /^data(-[a-z]*)*$/; + return !!dataAttrConvention.test(name); +} + +/** + * Checks if an attribute name is a standard aria attribute by compering it to a list + * of standard aria property names + * + * @param {String} name - Attribute name to be tested + * @returns {Boolean} Result + */ + +function isValidAriaAttribute(name) { + return ARIA_PROPERTIES.some((element) => element === name); +} + +/** + * Checks if the attribute name is included in the attributes that are excluded + * from the camel casing. + * + * // returns true + * @example isCaseIgnoredAttribute('charSet') + * + * Note - these exclusions are not made by React core team, but `eslint-plugin-react` community. + * + * @param {String} name - Attribute name to be tested + * @returns {Boolean} Result + */ + +function isCaseIgnoredAttribute(name) { + return DOM_PROPERTIES_IGNORE_CASE.some((element) => element === name.toLowerCase()); +} + /** * Extracts the tag name for the JSXAttribute * @param {JSXAttribute} node - JSXAttribute being tested. @@ -215,7 +310,8 @@ function getStandardName(name, context) { const messages = { invalidPropOnTag: 'Invalid property \'{{name}}\' found on tag \'{{tagName}}\', but it is only allowed on: {{allowedTags}}', - unknownProp: 'Unknown property \'{{name}}\' found, use \'{{standardName}}\' instead', + unknownPropWithStandardName: 'Unknown property \'{{name}}\' found, use \'{{standardName}}\' instead', + unknownProp: 'Unknown property \'{{name}}\' found', }; module.exports = { @@ -262,39 +358,68 @@ module.exports = { return; } + if (isValidDataAttribute(name)) { return; } + + if (isValidAriaAttribute(name)) { return; } + + if (isCaseIgnoredAttribute(name)) { return; } + const tagName = getTagName(node); - // 1. Some attributes are allowed on some tags only. - const allowedTags = has(ATTRIBUTE_TAGS_MAP, name) ? ATTRIBUTE_TAGS_MAP[name] : null; - if (tagName && allowedTags && /[^A-Z]/.test(tagName.charAt(0)) && allowedTags.indexOf(tagName) === -1) { - report(context, messages.invalidPropOnTag, 'invalidPropOnTag', { + // Let's dive deeper into tags that are HTML/DOM elements (`;' }, + // Case ignored attributes, for `charset` discussion see https://github.com/jsx-eslint/eslint-plugin-react/pull/1863 + { code: ';' }, + { code: ';' }, + // Some custom web components that are allowed to use `class` instead of `className` { code: '
;' }, { code: '
;' }, - { code: ';' }, { + { code: ';' }, + // data-* attributes should work + { code: '
;' }, + { code: '
;' }, + { code: '
;' }, + { code: '
;' }, + // Ignoring should work + { code: '
;', options: [{ ignore: ['class'] }], }, + { + code: '
;', + options: [{ ignore: ['someProp'] }], + }, + + // aria-* attributes should work + { code: ';' }, + { code: '