diff --git a/eslint-suppressions.json b/eslint-suppressions.json index ae4979a2750..b0566228338 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -584,6 +584,11 @@ "count": 1 } }, + "packages/grafana-ui/src/components/Cascader/styles.ts": { + "@grafana/no-unreduced-motion": { + "count": 17 + } + }, "packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx": { "@typescript-eslint/no-explicit-any": { "count": 2 diff --git a/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs b/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs index a8a691c9ed7..821b31e13d3 100644 --- a/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs +++ b/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs @@ -1,4 +1,5 @@ // @ts-check +/** @typedef {import('@typescript-eslint/utils/ts-eslint').RuleContext} RuleContext */ const { ESLintUtils, AST_NODE_TYPES } = require('@typescript-eslint/utils'); const createRule = ESLintUtils.RuleCreator( @@ -6,9 +7,38 @@ const createRule = ESLintUtils.RuleCreator( ); const restrictedProperties = ['animation', 'transition']; +const excludedProperties = ['transitionProperty']; const isRestrictedProperty = (/** @type string */ propertyName) => { - return restrictedProperties.some((prop) => propertyName.startsWith(prop)); + return ( + !excludedProperties.includes(propertyName) && restrictedProperties.some((prop) => propertyName.startsWith(prop)) + ); +}; + +/** + * @param {import('@typescript-eslint/utils').TSESTree.ObjectExpression} obj + * @param {import('@typescript-eslint/utils/ts-eslint').RuleContext} context + */ +const checkProperties = (obj, context) => { + for (const property of obj.properties) { + if (property.type !== AST_NODE_TYPES.Property) { + continue; + } + + if ( + property.value.type === AST_NODE_TYPES.ObjectExpression && + property.key.type !== AST_NODE_TYPES.CallExpression + ) { + checkProperties(property.value, context); + } + + if (property.key.type === AST_NODE_TYPES.Identifier && isRestrictedProperty(property.key.name)) { + context.report({ + node: property, + messageId: 'noUnreducedMotion', + }); + } + } }; const rule = createRule({ @@ -29,18 +59,7 @@ const rule = createRule({ for (const cssObject of cssObjects) { if (cssObject?.type === AST_NODE_TYPES.ObjectExpression) { - for (const property of cssObject.properties) { - if ( - property.type === AST_NODE_TYPES.Property && - property.key.type === AST_NODE_TYPES.Identifier && - isRestrictedProperty(property.key.name) - ) { - context.report({ - node: property, - messageId: 'noUnreducedMotion', - }); - } - } + checkProperties(cssObject, context); } } } diff --git a/packages/grafana-eslint-rules/tests/no-unreduced-motion.test.js b/packages/grafana-eslint-rules/tests/no-unreduced-motion.test.js new file mode 100644 index 00000000000..2e4244c0ad4 --- /dev/null +++ b/packages/grafana-eslint-rules/tests/no-unreduced-motion.test.js @@ -0,0 +1,65 @@ +import { RuleTester } from 'eslint'; + +import noUnreducedMotion from '../rules/no-unreduced-motion.cjs'; + +RuleTester.setDefaultConfig({ + languageOptions: { + ecmaVersion: 2018, + sourceType: 'module', + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + }, + }, +}); + +const ruleTester = new RuleTester(); + +ruleTester.run('eslint no-unreduced-motion', noUnreducedMotion, { + valid: [ + { + name: 'basic case with handled preference', + code: ` +css({ + [theme.transitions.handleMotion('no-preference')]: { + transition: 'opacity 0.5s ease-in-out', + }, +}) +`, + }, + { + name: 'basic case ignored property', + code: ` +css({ + transitionProperty: 'opacity', +}) +`, + }, + ], + invalid: [ + { + name: 'basic case', + code: ` +css({ + transition: 'opacity 0.5s ease-in-out', +}) +`, + errors: 1, + }, + { + name: 'invalid usage in nested property or pseudo element', + code: ` +css({ + foo: { + transition: 'opacity 0.5s ease-in-out', + }, + '&:before': { + transition: 'opacity 0.5s ease-in-out', + }, +}) + `, + errors: 2, + }, + ], +}); diff --git a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx index d6a844b1f18..12c00333dfd 100644 --- a/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx +++ b/packages/grafana-ui/src/components/Collapse/CollapsableSection.tsx @@ -122,7 +122,9 @@ const collapsableSectionStyles = (theme: GrafanaTheme2) => ({ '&:focus-visible': { outline: 'none', outlineOffset: 'unset', - transition: 'none', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'none', + }, boxShadow: 'none', }, }), diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.tsx index 4089959a6ef..57aec7a3ed4 100644 --- a/packages/grafana-ui/src/components/Collapse/Collapse.tsx +++ b/packages/grafana-ui/src/components/Collapse/Collapse.tsx @@ -51,8 +51,14 @@ const getStyles = (theme: GrafanaTheme2) => ({ top: 0, height: '250%', position: 'absolute', - animation: 'loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67) 500ms', - animationIterationCount: 100, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + animation: 'loader 2s cubic-bezier(0.17, 0.67, 0.83, 0.67) 500ms', + animationIterationCount: 100, + }, + [theme.transitions.handleMotion('reduce')]: { + animationDuration: '10s', + animationIterationCount: 20, + }, left: '-25%', background: theme.colors.primary.main, }, diff --git a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx index ef916ecc76a..77cd0b63579 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx @@ -196,7 +196,9 @@ const getStyles = (theme: GrafanaTheme2) => { '&:hover': { '.thumb-vertical, .thumb-horizontal': { opacity: 1, - transition: 'opacity 0.3s ease-in-out', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'opacity 0.3s ease-in-out', + }, }, }, }), diff --git a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap index bfdedc58db4..9b2114e69f6 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap +++ b/packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap @@ -3,7 +3,7 @@ exports[`CustomScrollbar renders correctly 1`] = `
{ }), drawerMotion: css({ '&-appear': { - transform: 'translateX(100%)', - transition: 'none !important', - + [theme.transitions.handleMotion('no-preference')]: { + transform: 'translateX(100%)', + transition: 'none !important', + }, + [theme.transitions.handleMotion('reduce')]: { + opacity: 0, + }, '&-active': { - transition: `${theme.transitions.create('transform')} !important`, - transform: 'translateX(0)', + [theme.transitions.handleMotion('no-preference')]: { + transform: 'translateX(0)', + transition: `${theme.transitions.create('transform')} !important`, + }, + [theme.transitions.handleMotion('reduce')]: { + transition: `opacity 0.2s ease-in-out`, + opacity: 1, + }, }, }, }), @@ -308,7 +318,9 @@ const getStyles = (theme: GrafanaTheme2) => { '&-active': { opacity: 1, - transition: theme.transitions.create('opacity'), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create('opacity'), + }, }, }, }), diff --git a/packages/grafana-ui/src/components/Forms/Legacy/Switch/Switch.tsx b/packages/grafana-ui/src/components/Forms/Legacy/Switch/Switch.tsx index e6a193b2ec3..c25223ae6e8 100644 --- a/packages/grafana-ui/src/components/Forms/Legacy/Switch/Switch.tsx +++ b/packages/grafana-ui/src/components/Forms/Legacy/Switch/Switch.tsx @@ -108,7 +108,9 @@ const getStyles = (theme: GrafanaTheme2) => { left: '2px', top: '2px', background: theme.components.input.background, - transition: '0.4s', + [theme.transitions.handleMotion('no-preference')]: { + transition: '0.4s', + }, borderRadius: theme.shape.radius.circle, boxShadow: theme.shadows.z1, }, diff --git a/packages/grafana-ui/src/components/Switch/Switch.tsx b/packages/grafana-ui/src/components/Switch/Switch.tsx index 8bde19dc0c8..0a8654b1425 100644 --- a/packages/grafana-ui/src/components/Switch/Switch.tsx +++ b/packages/grafana-ui/src/components/Switch/Switch.tsx @@ -137,7 +137,9 @@ const getSwitchStyles = (theme: GrafanaTheme2, transparent?: boolean) => ({ borderRadius: theme.shape.radius.pill, background: theme.components.input.background, border: `1px solid ${theme.components.input.borderColor}`, - transition: 'all 0.3s ease', + [theme.transitions.handleMotion('no-preference')]: { + transition: 'all 0.3s ease', + }, '&:hover': { borderColor: theme.components.input.borderHover, @@ -155,7 +157,9 @@ const getSwitchStyles = (theme: GrafanaTheme2, transparent?: boolean) => ({ left: 0, top: '50%', transform: `translate3d(${theme.spacing(0.25)}, -50%, 0)`, - transition: 'transform 0.2s cubic-bezier(0.19, 1, 0.22, 1)', + [theme.transitions.handleMotion('no-preference')]: { + transition: 'transform 0.2s cubic-bezier(0.19, 1, 0.22, 1)', + }, '@media (forced-colors: active)': { border: `1px solid ${theme.colors.primary.contrastText}`, diff --git a/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts b/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts index 5c631b33f6e..f90dce2baa0 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/dashboardGrid.ts @@ -64,6 +64,7 @@ export function getDashboardGridStyles(theme: GrafanaTheme2) { // Disable animation on initial rendering and enable it when component has been mounted. '.react-grid-item.cssTransforms': { + // eslint-disable-next-line @grafana/no-unreduced-motion transitionProperty: 'none !important', }, @@ -93,7 +94,9 @@ export function getDashboardGridStyles(theme: GrafanaTheme2) { '.dashboard-canvas-add-button': { display: 'flex', opacity: 0.5, - transition: theme.transitions.create('opacity'), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create('opacity'), + }, filter: `grayscale(100%)`, '&:hover,:focus-within': { opacity: 1, diff --git a/packages/grafana-ui/src/themes/GlobalStyles/jsonFormatter.ts b/packages/grafana-ui/src/themes/GlobalStyles/jsonFormatter.ts index 24d2d3eea07..aa589bde70a 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/jsonFormatter.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/jsonFormatter.ts @@ -89,7 +89,9 @@ export function getJsonFormatterStyles(theme: GrafanaTheme2) { '&::after': { display: 'inline-block', - transition: 'transform 100ms ease-in', + [theme.transitions.handleMotion('no-preference')]: { + transition: 'transform 100ms ease-in', + }, content: "'►'", }, }, @@ -97,7 +99,9 @@ export function getJsonFormatterStyles(theme: GrafanaTheme2) { // Inline preview on hover (optional) '> a > .json-formatter-preview-text': { opacity: 0, - transition: 'opacity 0.15s ease-in', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'opacity 0.15s ease-in', + }, fontStyle: 'italic', }, diff --git a/packages/grafana-ui/src/themes/GlobalStyles/slate.ts b/packages/grafana-ui/src/themes/GlobalStyles/slate.ts index e8fd858d456..d6988dd314f 100644 --- a/packages/grafana-ui/src/themes/GlobalStyles/slate.ts +++ b/packages/grafana-ui/src/themes/GlobalStyles/slate.ts @@ -24,7 +24,9 @@ export function getSlateStyles(theme: GrafanaTheme2) { backgroundImage: 'none', border: `1px solid ${theme.components.input.borderColor}`, borderRadius: theme.shape.radius.default, - transition: 'all 0.3s', + [theme.transitions.handleMotion('no-preference')]: { + transition: 'all 0.3s', + }, lineHeight: '18px', }, @@ -67,8 +69,10 @@ export function getSlateStyles(theme: GrafanaTheme2) { display: 'block', whiteSpace: 'nowrap', cursor: 'pointer', - transition: - 'color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1)', + [theme.transitions.handleMotion('no-preference')]: { + transition: + 'color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1)', + }, }, '.typeahead-item__selected': { diff --git a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx index b76e6006d78..672a24cdc3b 100644 --- a/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx +++ b/public/app/core/components/AppChrome/MegaMenu/MegaMenuItemText.tsx @@ -124,7 +124,6 @@ const getStyles = (theme: GrafanaTheme2, isActive: Props['isActive']) => ({ boxShadow: 'none', outline: `2px solid ${theme.colors.primary.main}`, outlineOffset: '-2px', - transition: 'none', }, }), linkContent: css({ diff --git a/public/app/core/components/Branding/Branding.tsx b/public/app/core/components/Branding/Branding.tsx index d87f2e1b333..b32199ed4c7 100644 --- a/public/app/core/components/Branding/Branding.tsx +++ b/public/app/core/components/Branding/Branding.tsx @@ -33,7 +33,10 @@ const LoginBackground: FC = ({ className, children }) => { backgroundRepeat: 'no-repeat', opacity: 0, - transition: 'opacity 3s ease-in-out', + + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'opacity 3s ease-in-out', + }, [theme.breakpoints.up('md')]: { backgroundPosition: 'center', diff --git a/public/app/core/components/Login/LoginLayout.tsx b/public/app/core/components/Login/LoginLayout.tsx index 00e0e63b21e..ed6980d5e73 100644 --- a/public/app/core/components/Login/LoginLayout.tsx +++ b/public/app/core/components/Login/LoginLayout.tsx @@ -153,9 +153,12 @@ export const getLoginStyles = (theme: GrafanaTheme2) => { borderRadius: theme.shape.radius.lg, padding: theme.spacing(2, 0), opacity: 0, - [theme.transitions.handleMotion('no-preference', 'reduce')]: { + [theme.transitions.handleMotion('no-preference')]: { transition: 'opacity 0.5s ease-in-out', }, + [theme.transitions.handleMotion('reduce')]: { + opacity: 1, + }, [theme.breakpoints.up('sm')]: { minHeight: theme.spacing(40), diff --git a/public/app/features/dashboard/containers/DashboardPage.tsx b/public/app/features/dashboard/containers/DashboardPage.tsx index 16ed48ea618..21f55b6d8e0 100644 --- a/public/app/features/dashboard/containers/DashboardPage.tsx +++ b/public/app/features/dashboard/containers/DashboardPage.tsx @@ -84,10 +84,12 @@ const getStyles = (theme: GrafanaTheme2) => ({ fullScreenPanel: css({ '.react-grid-layout': { height: 'auto !important', + // eslint-disable-next-line @grafana/no-unreduced-motion transitionProperty: 'none', }, '.react-grid-item': { display: 'none !important', + // eslint-disable-next-line @grafana/no-unreduced-motion transitionProperty: 'none !important', '&--fullscreen': { diff --git a/public/app/features/explore/TraceView/components/TracePageHeader/Actions/ActionButton.tsx b/public/app/features/explore/TraceView/components/TracePageHeader/Actions/ActionButton.tsx index 31b10fc96b1..d46d0a7fc52 100644 --- a/public/app/features/explore/TraceView/components/TracePageHeader/Actions/ActionButton.tsx +++ b/public/app/features/explore/TraceView/components/TracePageHeader/Actions/ActionButton.tsx @@ -17,12 +17,16 @@ export const getStyles = (theme: GrafanaTheme2) => ({ width: '100%', height: '100%', opacity: 0, - transition: 'all 0.8s', + [theme.transitions.handleMotion('no-preference')]: { + transition: 'all 0.8s', + }, }, '&:active:after': { margin: 0, opacity: 0.3, - transition: '0s', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: '0s', + }, }, }), }); diff --git a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx index c01bbbe4abb..48891aae1cd 100644 --- a/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx +++ b/public/app/features/explore/TraceView/components/TraceTimelineViewer/SpanBarRow.tsx @@ -189,7 +189,9 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, showSpanFilterMatchesOnly }, [`& .${nameWrapperClassName}, .${viewClassName}, .${nameWrapperMatchingFilterClassName}`]: { backgroundColor: autoColor(theme, '#cbe7ff'), - animation: `${animations.flash} 1s cubic-bezier(0.12, 0, 0.39, 0)`, + [theme.transitions.handleMotion('no-preference')]: { + animation: `${animations.flash} 1s cubic-bezier(0.12, 0, 0.39, 0)`, + }, }, [`& .${spanBarClassName}`]: { opacity: 1, diff --git a/public/app/plugins/panel/datagrid/utils.ts b/public/app/plugins/panel/datagrid/utils.ts index 2fb003578fb..d4130660334 100644 --- a/public/app/plugins/panel/datagrid/utils.ts +++ b/public/app/plugins/panel/datagrid/utils.ts @@ -285,7 +285,9 @@ export const getStyles = (theme: GrafanaTheme2, isResizeInProgress: boolean) => color: theme.colors.text.primary, borderRight: `1px solid ${theme.components.panel.borderColor}`, borderBottom: `1px solid ${theme.components.panel.borderColor}`, - transition: 'background-color 200ms', + [theme.transitions.handleMotion('no-preference')]: { + transition: 'background-color 200ms', + }, cursor: 'pointer', ':hover': { backgroundColor: theme.colors.background.secondary,