@atlaskit/eslint-plugin-design-system
Version:
The essential plugin for use with the Atlassian Design System.
132 lines (121 loc) • 6.05 kB
JavaScript
import { isNodeOfType } from 'eslint-codemod-utils';
import { JSXElementHelper } from '../../ast-nodes/jsx-element-helper';
import { createLintRule } from '../utils/create-lint-rule';
import { getFormImportLocalNames } from '../utils/get-form-import-local-names';
import { isImportFromPackage } from '../utils/is-import-from-package';
const TEXTFIELD_PACKAGE = '@atlaskit/textfield';
const TEXTAREA_PACKAGE = '@atlaskit/textarea';
export const messageInsideForm = 'When using `maxLength` or `minLength` props with Textfield/Textarea inside a Form, use `CharacterCounterField` from `@atlaskit/form` instead of Field and remove the props from the Textfield/Textarea. This ensures accessibility through real time feedback and aligns with the design system. Read more about [character counter fields](https://atlassian.design/components/form/examples#charactercounterfield)';
export const messageOutsideForm = 'When using `maxLength` or `minLength` props with Textfield/Textarea, use `CharacterCounter` from `@atlaskit/form` alongside your input for real-time character count feedback. This ensures accessibility through screen reader announcements and visual feedback. Read more about [standalone character counter](https://atlassian.design/components/form/examples#standalone-charactercounter)';
export const ruleName = __dirname.split('/').slice(-1)[0];
/**
* Check if a node is inside a Form component or Field component
*/
function isInsideFormOrField(node, formComponentNames) {
let current = node;
// Traverse up the AST to find parent JSX elements
while (current) {
const parent = current.parent;
if (!parent) {
break;
}
// Check if parent is a JSXElement with a name we're tracking
if (isNodeOfType(parent, 'JSXElement') && isNodeOfType(parent.openingElement.name, 'JSXIdentifier')) {
const parentName = parent.openingElement.name.name;
// Check if this is a Form component or Field component
if (formComponentNames.has(parentName)) {
return true;
}
}
// Check if parent is inside a JSXExpressionContainer (render prop pattern)
// This handles: <Field>{({ fieldProps }) => <Textfield {...fieldProps} />}</Field>
if (isNodeOfType(parent, 'JSXExpressionContainer')) {
// Continue traversing up to find the Field/Form
current = parent;
continue;
}
// Check if parent is an arrow function (render prop)
if (isNodeOfType(parent, 'ArrowFunctionExpression')) {
current = parent;
continue;
}
current = parent;
}
return false;
}
const rule = createLintRule({
meta: {
name: ruleName,
type: 'suggestion',
docs: {
description: 'Suggests using CharacterCounterField or CharacterCounter when Textfield or Textarea components have maxLength or minLength props.',
recommended: true,
severity: 'warn'
},
messages: {
useCharacterCounterField: messageInsideForm,
useCharacterCounter: messageOutsideForm
}
},
create(context) {
// Track imported component names
const importedComponents = new Map();
const formComponentNames = new Set();
return {
ImportDeclaration(node) {
var _formImports$Form, _formImports$Field, _formImports$Characte, _formImports$Checkbox, _formImports$RangeFie;
const source = node.source.value;
if (typeof source !== 'string') {
return;
}
// Track Form package imports (Form, Field, etc.)
const formImports = getFormImportLocalNames(node);
[...((_formImports$Form = formImports.Form) !== null && _formImports$Form !== void 0 ? _formImports$Form : []), ...((_formImports$Field = formImports.Field) !== null && _formImports$Field !== void 0 ? _formImports$Field : []), ...((_formImports$Characte = formImports.CharacterCounterField) !== null && _formImports$Characte !== void 0 ? _formImports$Characte : []), ...((_formImports$Checkbox = formImports.CheckboxField) !== null && _formImports$Checkbox !== void 0 ? _formImports$Checkbox : []), ...((_formImports$RangeFie = formImports.RangeField) !== null && _formImports$RangeFie !== void 0 ? _formImports$RangeFie : [])].forEach(localName => formComponentNames.add(localName));
// Track Textfield and Textarea imports
if (isImportFromPackage(source, TEXTFIELD_PACKAGE) || isImportFromPackage(source, TEXTAREA_PACKAGE)) {
const defaultImport = node.specifiers.find(spec => isNodeOfType(spec, 'ImportDefaultSpecifier'));
if (defaultImport && isNodeOfType(defaultImport, 'ImportDefaultSpecifier')) {
const localName = defaultImport.local.name;
importedComponents.set(localName, {
localName,
package: source
});
}
}
},
JSXElement(node) {
if (!isNodeOfType(node, 'JSXElement')) {
return;
}
if (!isNodeOfType(node.openingElement.name, 'JSXIdentifier')) {
return;
}
const elementName = node.openingElement.name.name;
// Check if this element is one of our tracked components
const componentInfo = importedComponents.get(elementName);
if (!componentInfo) {
return;
}
// Check if the component has maxLength or minLength props
const hasMaxLength = JSXElementHelper.getAttributeByName(node, 'maxLength');
const hasMinLength = JSXElementHelper.getAttributeByName(node, 'minLength');
if (hasMaxLength || hasMinLength) {
// Use different messages based on whether inside Form context or not
if (isInsideFormOrField(node, formComponentNames)) {
context.report({
node,
messageId: 'useCharacterCounterField'
});
} else {
context.report({
node,
messageId: 'useCharacterCounter'
});
}
}
}
};
}
});
// eslint-disable-next-line @atlaskit/volt-strict-mode/no-multiple-exports
export default rule;