@atlaskit/eslint-plugin-design-system
Version:
The essential plugin for use with the Atlassian Design System.
364 lines • 18.3 kB
JavaScript
import { getSourceCode } from '@atlaskit/eslint-utils/context-compat';
import { Root } from '../../ast-nodes/root';
import { createLintRule } from '../utils/create-lint-rule';
const COMPILED_PRIMITIVES = '@atlaskit/primitives/compiled';
const COMPILED_PRESSABLE = '@atlaskit/primitives/compiled/pressable';
const LEGACY_PRESSABLE = '@atlaskit/primitives/pressable';
const XCSS = '@atlaskit/primitives/xcss';
const ATLASKIT_CSS = '@atlaskit/css';
const COMPILED_REACT = '@compiled/react';
const TOKENS = '@atlaskit/tokens';
const BUTTON_PROPERTIES = new Set(['backgroundColor', 'background']);
const LIST_ITEM_PROPERTIES = new Set(['backgroundColor', 'background', 'borderColor', 'color', 'textDecorationColor']);
const CSS_SELECTORS = {
hover: '&:hover',
active: '&:active'
};
const XCSS_SELECTORS = {
hover: ':hover',
active: ':active'
};
const getPropertyName = property => {
if (property.computed) {
return;
}
if (property.key.type === 'Identifier') {
return property.key.name;
}
return property.key.type === 'Literal' && typeof property.key.value === 'string' ? property.key.value : undefined;
};
const getProperty = (object, name) => object.properties.find(property => property.type === 'Property' && getPropertyName(property) === name);
const getNestedStyle = (base, selector) => {
const property = getProperty(base, selector);
return (property === null || property === void 0 ? void 0 : property.value.type) === 'ObjectExpression' ? property.value : undefined;
};
const createStyleRecord = (base, selectors = CSS_SELECTORS) => ({
base,
hover: getNestedStyle(base, selectors.hover),
active: getNestedStyle(base, selectors.active)
});
const hasSpread = object => object.properties.some(property => property.type === 'SpreadElement');
const isColorOnlyBackground = (property, tokenNames) => {
var _property$value$argum;
if (getPropertyName(property) !== 'background') {
return false;
}
if (property.value.type === 'Literal' && typeof property.value.value === 'string') {
const value = property.value.value.trim().toLowerCase();
return !/\b(url|image|gradient|var)\s*\(/.test(value) && !value.includes(',');
}
return property.value.type === 'CallExpression' && property.value.callee.type === 'Identifier' && tokenNames.has(property.value.callee.name) && ((_property$value$argum = property.value.arguments[0]) === null || _property$value$argum === void 0 ? void 0 : _property$value$argum.type) === 'Literal' && typeof property.value.arguments[0].value === 'string' && property.value.arguments[0].value.startsWith('color.');
};
const hasRelevantColorChange = (object, properties, tokenNames) => {
if (!object || hasSpread(object)) {
return false;
}
return object.properties.some(property => {
if (property.type !== 'Property') {
return false;
}
const name = getPropertyName(property);
return name === 'background' && isColorOnlyBackground(property, tokenNames) || name !== undefined && properties.has(name) && name !== 'background';
});
};
const getIndent = (context, node) => {
var _sourceCode$lines, _node$loc$start$line, _node$loc, _line$match$, _line$match;
const sourceCode = getSourceCode(context);
const line = (_sourceCode$lines = sourceCode.lines[((_node$loc$start$line = (_node$loc = node.loc) === null || _node$loc === void 0 ? void 0 : _node$loc.start.line) !== null && _node$loc$start$line !== void 0 ? _node$loc$start$line : 1) - 1]) !== null && _sourceCode$lines !== void 0 ? _sourceCode$lines : '';
return (_line$match$ = (_line$match = line.match(/^\s*/)) === null || _line$match === void 0 ? void 0 : _line$match[0]) !== null && _line$match$ !== void 0 ? _line$match$ : '';
};
const insertTransition = (context, fixer, object, value) => {
var _object$loc, _lastProperty$loc;
const lastProperty = object.properties[object.properties.length - 1];
const isInlineObject = ((_object$loc = object.loc) === null || _object$loc === void 0 ? void 0 : _object$loc.start.line) === ((_lastProperty$loc = lastProperty.loc) === null || _lastProperty$loc === void 0 ? void 0 : _lastProperty$loc.start.line);
return fixer.insertTextAfter(lastProperty, isInlineObject ? `, transition: ${value}` : `,\n${getIndent(context, lastProperty)}transition: ${value}`);
};
const isExpectedTransition = (property, tokenNames, tokenName) => {
var _property$value$argum2;
return (property === null || property === void 0 ? void 0 : property.value.type) === 'CallExpression' && property.value.callee.type === 'Identifier' && tokenNames.has(property.value.callee.name) && ((_property$value$argum2 = property.value.arguments[0]) === null || _property$value$argum2 === void 0 ? void 0 : _property$value$argum2.type) === 'Literal' && property.value.arguments[0].value === tokenName;
};
const rule = createLintRule({
meta: {
name: 'use-pressable-motion',
hasSuggestions: true,
type: 'suggestion',
docs: {
description: 'Suggests semantic motion tokens when Pressable or native buttons change interactive colours through local static styles.',
removeFromPresets: true
},
messages: {
missingPressableMotion: 'An interactive element changes colour properties without the matching semantic motion token.',
missingPressableMotionManual: 'An interactive element changes colour properties without the matching semantic motion token. Update the existing transition manually.',
useButtonMotion: 'Add button motion tokens for hover and pressed states.',
useListItemMotion: 'Add list-item motion tokens for hover and pressed states.'
}
},
create(context) {
const pressableNames = new Set();
const cssNames = new Set();
const cssMapNames = new Set();
const xcssNames = new Set();
const tokenNames = new Set();
const styles = new Map();
const styleUsages = [];
const collectStyleUsages = (node, expression, arrayGroup) => {
if (expression.type === 'Identifier') {
styleUsages.push({
node,
styleName: expression.name,
styleKey: 'default',
arrayGroup
});
return;
}
if (expression.type === 'MemberExpression' && !expression.computed && expression.object.type === 'Identifier' && expression.property.type === 'Identifier') {
styleUsages.push({
node,
styleName: expression.object.name,
styleKey: expression.property.name,
arrayGroup
});
return;
}
if (expression.type === 'ArrayExpression') {
expression.elements.forEach(element => {
if (element !== null && element.type !== 'SpreadElement') {
collectStyleUsages(node, element, expression);
}
});
return;
}
if (expression.type === 'LogicalExpression') {
collectStyleUsages(node, expression.right, arrayGroup);
return;
}
if (expression.type === 'ConditionalExpression') {
collectStyleUsages(node, expression.consequent, arrayGroup);
collectStyleUsages(node, expression.alternate, arrayGroup);
}
};
const report = (node, style, groupProvidesButtonHoverTransition, groupProvidesListItemHoverTransition, groupProvidesButtonActiveTransition, groupProvidesListItemActiveTransition) => {
const hasHover = hasRelevantColorChange(style.hover, BUTTON_PROPERTIES, tokenNames);
const hasActive = hasRelevantColorChange(style.active, BUTTON_PROPERTIES, tokenNames);
const hasListItemHover = hasRelevantColorChange(style.hover, LIST_ITEM_PROPERTIES, tokenNames);
const hasListItemActive = hasRelevantColorChange(style.active, LIST_ITEM_PROPERTIES, tokenNames);
if (!hasListItemHover && !hasListItemActive) {
return;
}
const baseTransition = getProperty(style.base, 'transition');
const activeTransition = style.active ? getProperty(style.active, 'transition') : undefined;
const hasButtonHoverTransition = isExpectedTransition(baseTransition, tokenNames, 'motion.button.hovered') || groupProvidesButtonHoverTransition;
const hasButtonActiveTransition = isExpectedTransition(activeTransition, tokenNames, 'motion.button.pressed') || groupProvidesButtonActiveTransition;
const hasListItemHoverTransition = isExpectedTransition(baseTransition, tokenNames, 'motion.listitem.hovered') || groupProvidesListItemHoverTransition;
const hasListItemActiveTransition = isExpectedTransition(activeTransition, tokenNames, 'motion.listitem.pressed') || groupProvidesListItemActiveTransition;
const groupProvidesHoverTransition = hasHover ? groupProvidesButtonHoverTransition || groupProvidesListItemHoverTransition : groupProvidesListItemHoverTransition;
const groupProvidesActiveTransition = hasActive ? groupProvidesButtonActiveTransition || groupProvidesListItemActiveTransition : groupProvidesListItemActiveTransition;
const needsHover = hasListItemHover && !baseTransition && !groupProvidesHoverTransition;
const needsActive = hasListItemActive && !activeTransition && !groupProvidesActiveTransition;
const hasUnsafeTransition = hasListItemHover && baseTransition !== undefined && !hasButtonHoverTransition && !hasListItemHoverTransition || hasListItemActive && activeTransition !== undefined && !hasButtonActiveTransition && !hasListItemActiveTransition;
if (hasUnsafeTransition) {
context.report({
node,
messageId: 'missingPressableMotionManual'
});
return;
}
if (!needsHover && !needsActive) {
return;
}
const createSuggestion = family => ({
messageId: family === 'button' ? 'useButtonMotion' : 'useListItemMotion',
fix(fixer) {
var _tokenNames$values$ne;
const fixes = [];
const tokenIdentifier = (_tokenNames$values$ne = tokenNames.values().next().value) !== null && _tokenNames$values$ne !== void 0 ? _tokenNames$values$ne : 'token';
if (needsHover) {
fixes.push(insertTransition(context, fixer, style.base, `${tokenIdentifier}('motion.${family}.hovered')`));
}
if (needsActive && style.active) {
fixes.push(insertTransition(context, fixer, style.active, `${tokenIdentifier}('motion.${family}.pressed')`));
}
if (tokenNames.size === 0) {
const importFix = Root.upsertNamedImportDeclaration({
module: TOKENS,
specifiers: ['token']
}, context, fixer);
if (importFix) {
fixes.push(importFix);
}
}
return fixes;
}
});
const suggestions = [...(hasHover || hasActive ? [createSuggestion('button')] : []), createSuggestion('listitem')];
context.report({
node,
messageId: 'missingPressableMotion',
suggest: suggestions
});
};
const providesButtonHoverTransition = style => {
const baseTransition = getProperty(style.base, 'transition');
return isExpectedTransition(baseTransition, tokenNames, 'motion.button.hovered');
};
const providesListItemHoverTransition = style => {
const baseTransition = getProperty(style.base, 'transition');
return isExpectedTransition(baseTransition, tokenNames, 'motion.listitem.hovered');
};
const providesButtonActiveTransition = style => {
if (!style.active) {
return false;
}
const activeTransition = getProperty(style.active, 'transition');
return isExpectedTransition(activeTransition, tokenNames, 'motion.button.pressed');
};
const providesListItemActiveTransition = style => {
if (!style.active) {
return false;
}
const activeTransition = getProperty(style.active, 'transition');
return isExpectedTransition(activeTransition, tokenNames, 'motion.listitem.pressed');
};
return {
ImportDeclaration(node) {
if (node.type !== 'ImportDeclaration') {
return;
}
if (node.source.value === COMPILED_PRIMITIVES || node.source.value === COMPILED_PRESSABLE || node.source.value === LEGACY_PRESSABLE) {
node.specifiers.forEach(specifier => {
if (specifier.type === 'ImportDefaultSpecifier' || specifier.type === 'ImportSpecifier' && specifier.imported.type === 'Identifier' && specifier.imported.name === 'Pressable') {
pressableNames.add(specifier.local.name);
}
});
}
if (node.source.value === ATLASKIT_CSS || node.source.value === COMPILED_REACT) {
node.specifiers.forEach(specifier => {
if (specifier.type !== 'ImportSpecifier' || specifier.imported.type !== 'Identifier') {
return;
}
if (specifier.imported.name === 'css') cssNames.add(specifier.local.name);
if (specifier.imported.name === 'cssMap') cssMapNames.add(specifier.local.name);
});
}
if (node.source.value === XCSS) {
node.specifiers.forEach(specifier => {
if (specifier.type === 'ImportSpecifier' && specifier.imported.type === 'Identifier' && specifier.imported.name === 'xcss') {
xcssNames.add(specifier.local.name);
}
});
}
if (node.source.value === TOKENS) {
node.specifiers.forEach(specifier => {
if (specifier.type === 'ImportSpecifier' && specifier.imported.type === 'Identifier' && specifier.imported.name === 'token') {
tokenNames.add(specifier.local.name);
}
});
}
},
VariableDeclarator(node) {
var _node$init;
if (node.type !== 'VariableDeclarator') {
return;
}
if (node.id.type !== 'Identifier' || ((_node$init = node.init) === null || _node$init === void 0 ? void 0 : _node$init.type) !== 'CallExpression' || node.init.callee.type !== 'Identifier' || node.init.arguments.length !== 1 || node.init.arguments[0].type !== 'ObjectExpression') {
return;
}
const styleObject = node.init.arguments[0];
if (hasSpread(styleObject)) {
return;
}
if (cssNames.has(node.init.callee.name)) {
styles.set(node.id.name, {
styles: new Map([['default', createStyleRecord(styleObject)]])
});
return;
}
if (xcssNames.has(node.init.callee.name)) {
styles.set(node.id.name, {
styles: new Map([['default', createStyleRecord(styleObject, XCSS_SELECTORS)]])
});
return;
}
if (!cssMapNames.has(node.init.callee.name)) {
return;
}
const cssMapStyles = new Map();
styleObject.properties.forEach(property => {
if (property.type !== 'Property' || property.value.type !== 'ObjectExpression') {
return;
}
const key = getPropertyName(property);
if (key && !hasSpread(property.value)) {
cssMapStyles.set(key, createStyleRecord(property.value));
}
});
styles.set(node.id.name, {
styles: cssMapStyles
});
},
JSXElement(node) {
var _styleAttribute$value;
if (node.type !== 'JSXElement') {
return;
}
if (node.openingElement.name.type !== 'JSXIdentifier') {
return;
}
const isPressable = pressableNames.has(node.openingElement.name.name);
const isNativeButton = node.openingElement.name.name === 'button';
if (!isPressable && !isNativeButton) {
return;
}
const styleAttribute = node.openingElement.attributes.find(attribute => attribute.type === 'JSXAttribute' && attribute.name.name === (isNativeButton ? 'css' : 'xcss'));
if ((styleAttribute === null || styleAttribute === void 0 ? void 0 : styleAttribute.type) !== 'JSXAttribute' || ((_styleAttribute$value = styleAttribute.value) === null || _styleAttribute$value === void 0 ? void 0 : _styleAttribute$value.type) !== 'JSXExpressionContainer') {
return;
}
const expression = styleAttribute.value.expression;
if (expression.type !== 'JSXEmptyExpression') {
collectStyleUsages(styleAttribute, expression);
}
},
'Program:exit'() {
const groups = new Map();
styleUsages.forEach(usage => {
if (!usage.arrayGroup) {
var _styles$get;
const style = (_styles$get = styles.get(usage.styleName)) === null || _styles$get === void 0 ? void 0 : _styles$get.styles.get(usage.styleKey);
if (style) {
report(usage.node, style, false, false, false, false);
}
return;
}
const existing = groups.get(usage.arrayGroup);
if (existing) {
existing.push(usage);
} else {
groups.set(usage.arrayGroup, [usage]);
}
});
groups.forEach(usages => {
const groupStyles = usages.map(usage => {
var _styles$get2;
return (_styles$get2 = styles.get(usage.styleName)) === null || _styles$get2 === void 0 ? void 0 : _styles$get2.styles.get(usage.styleKey);
}).filter(style => style !== undefined);
const groupProvidesButtonHoverTransition = groupStyles.some(providesButtonHoverTransition);
const groupProvidesListItemHoverTransition = groupStyles.some(providesListItemHoverTransition);
const groupProvidesButtonActiveTransition = groupStyles.some(providesButtonActiveTransition);
const groupProvidesListItemActiveTransition = groupStyles.some(providesListItemActiveTransition);
usages.forEach(({
node,
styleName,
styleKey
}) => {
var _styles$get3;
const style = (_styles$get3 = styles.get(styleName)) === null || _styles$get3 === void 0 ? void 0 : _styles$get3.styles.get(styleKey);
if (style) {
report(node, style, groupProvidesButtonHoverTransition, groupProvidesListItemHoverTransition, groupProvidesButtonActiveTransition, groupProvidesListItemActiveTransition);
}
});
});
}
};
}
});
export default rule;