Accessibility: Improve no-unreduced-motion rule and fix violations (#110304)

This commit is contained in:
Tom Ratcliffe
2025-10-21 12:46:11 +01:00
committed by GitHub
parent 6158167163
commit 11235e7153
21 changed files with 189 additions and 42 deletions
+5
View File
@@ -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
@@ -1,4 +1,5 @@
// @ts-check
/** @typedef {import('@typescript-eslint/utils/ts-eslint').RuleContext<string, []>} 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<string, []>} 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);
}
}
}
@@ -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,
},
],
});
@@ -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',
},
}),
@@ -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,
},
@@ -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',
},
},
},
}),
@@ -3,7 +3,7 @@
exports[`CustomScrollbar renders correctly 1`] = `
<div>
<div
class="css-1bz6b2c"
class="css-rbj06h"
style="position: relative; overflow: hidden; width: 100%; height: auto; min-height: 0; max-height: 100%;"
>
<div
@@ -33,7 +33,9 @@ export const getDragStyles = (theme: GrafanaTheme2, handlePosition?: DragHandleP
'&:before': {
content: '""',
position: 'absolute',
transition: theme.transitions.create('border-color'),
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create('border-color'),
},
zIndex: 1,
},
@@ -41,7 +43,9 @@ export const getDragStyles = (theme: GrafanaTheme2, handlePosition?: DragHandleP
background: baseColor,
content: '""',
position: 'absolute',
transition: theme.transitions.create('background'),
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create('background'),
},
transform: 'translate(-50%, -50%)',
borderRadius: theme.shape.radius.pill,
zIndex: 1,
@@ -273,12 +273,22 @@ const getStyles = (theme: GrafanaTheme2) => {
}),
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'),
},
},
},
}),
@@ -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,
},
@@ -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}`,
@@ -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,
@@ -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',
},
@@ -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': {
@@ -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({
@@ -33,7 +33,10 @@ const LoginBackground: FC<BrandComponentProps> = ({ 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',
@@ -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),
@@ -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': {
@@ -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',
},
},
}),
});
@@ -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,
+3 -1
View File
@@ -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,