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 =
;
+
+// 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 (`