Chore: Enable no-unreduced-motion and fix errors (#86572)

* enable `no-unreduced-motion` in betterer

* move all animation calls inside handleReducedMotion

* fix violations + enable rule

* update rule README

* remove unnecessary transition from <Collapse>

* remove handleReducedMotion utility and add handleMotion to theme

* update to use new theme value

* handle Dropdown and IconButton

* handle AppChromeMenu and update lint message

* keep rotation at a reduced speed

* handle DashboardLoading
This commit is contained in:
Ashley Harrison 2024-04-29 13:12:36 +01:00 committed by GitHub
parent 8a1f43a65d
commit c151a97110
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 321 additions and 132 deletions

View File

@ -8,6 +8,7 @@
}, },
"rules": { "rules": {
"@grafana/no-border-radius-literal": "error", "@grafana/no-border-radius-literal": "error",
"@grafana/no-unreduced-motion": "error",
"react/prop-types": "off", "react/prop-types": "off",
// need to ignore emotion's `css` prop, see https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unknown-property.md#rule-options // need to ignore emotion's `css` prop, see https://github.com/jsx-eslint/eslint-plugin-react/blob/master/docs/rules/no-unknown-property.md#rule-options
"react/no-unknown-property": ["error", { "ignore": ["css"] }], "react/no-unknown-property": ["error", { "ignore": ["css"] }],

View File

@ -53,6 +53,12 @@ export function create(props: string | string[] = ['all'], options: CreateTransi
.join(','); .join(',');
} }
type ReducedMotionProps = 'no-preference' | 'reduce';
export function handleMotion(...props: ReducedMotionProps[]) {
return props.map((prop) => `@media (prefers-reduced-motion: ${prop})`).join(',');
}
export function getAutoHeightDuration(height: number) { export function getAutoHeightDuration(height: number) {
if (!height) { if (!height) {
return 0; return 0;
@ -74,6 +80,7 @@ export interface ThemeTransitions {
duration: typeof duration; duration: typeof duration;
easing: typeof easing; easing: typeof easing;
getAutoHeightDuration: typeof getAutoHeightDuration; getAutoHeightDuration: typeof getAutoHeightDuration;
handleMotion: typeof handleMotion;
} }
/** @internal */ /** @internal */
@ -83,5 +90,6 @@ export function createTransitions(): ThemeTransitions {
duration, duration,
easing, easing,
getAutoHeightDuration, getAutoHeightDuration,
handleMotion,
}; };
} }

View File

@ -24,7 +24,89 @@ Avoid direct use of `animation*` or `transition*` properties.
To account for users with motion sensitivities, these should always be wrapped in a [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) media query. To account for users with motion sensitivities, these should always be wrapped in a [`prefers-reduced-motion`](https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-reduced-motion) media query.
`@grafana/ui` exposes a `handledReducedMotion` utility function that can be used to handle this. There is a `handleMotion` utility function exposed on the theme that can help with this.
#### Examples
```tsx
// Bad ❌
const getStyles = (theme: GrafanaTheme2) => ({
loading: css({
animationName: rotate,
animationDuration: '2s',
animationIterationCount: 'infinite',
}),
});
// Good ✅
const getStyles = (theme: GrafanaTheme2) => ({
loading: css({
[theme.transitions.handleMotion('no-preference')]: {
animationName: rotate,
animationDuration: '2s',
animationIterationCount: 'infinite',
},
[theme.transitions.handleMotion('reduce')]: {
animationName: pulse,
animationDuration: '2s',
animationIterationCount: 'infinite',
},
}),
});
// Good ✅
const getStyles = (theme: GrafanaTheme2) => ({
loading: css({
'@media (prefers-reduced-motion: no-preference)': {
animationName: rotate,
animationDuration: '2s',
animationIterationCount: 'infinite',
},
'@media (prefers-reduced-motion: reduce)': {
animationName: pulse,
animationDuration: '2s',
animationIterationCount: 'infinite',
},
}),
});
```
Note we've switched the potentially sensitive rotating animation to a less intense pulse animation when `prefers-reduced-motion` is set.
Animations that involve only non-moving properties, like opacity, color, and blurs, are unlikely to be problematic. In those cases, you still need to wrap the animation in a `prefers-reduced-motion` media query, but you can use the same animation for both cases:
```tsx
// Bad ❌
const getStyles = (theme: GrafanaTheme2) => ({
card: css({
transition: theme.transitions.create(['background-color'], {
duration: theme.transitions.duration.short,
}),
}),
});
// Good ✅
const getStyles = (theme: GrafanaTheme2) => ({
card: css({
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create(['background-color'], {
duration: theme.transitions.duration.short,
}),
},
}),
});
// Good ✅
const getStyles = (theme: GrafanaTheme2) => ({
card: css({
'@media (prefers-reduced-motion: no-preference), @media (prefers-reduced-motion: reduce)': {
transition: theme.transitions.create(['background-color'], {
duration: theme.transitions.duration.short,
}),
},
}),
});
```
### `theme-token-usage` ### `theme-token-usage`

View File

@ -55,7 +55,7 @@ const rule = createRule({
description: 'Check if animation or transition properties are used directly.', description: 'Check if animation or transition properties are used directly.',
}, },
messages: { messages: {
noUnreducedMotion: 'Avoid direct use of `animation*` or `transition*` properties. Use the `handleReducedMotion` utility function or wrap in a `prefers-reduced-motion` media query.', noUnreducedMotion: 'Avoid direct use of `animation*` or `transition*` properties. Use the `handleMotion` utility function from theme.transitions or wrap in a `prefers-reduced-motion` media query.',
}, },
schema: [], schema: [],
}, },

View File

@ -238,7 +238,9 @@ const getStyles = (theme: GrafanaTheme2) => {
borderRadius: theme.shape.radius.default, borderRadius: theme.shape.radius.default,
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
position: 'relative', position: 'relative',
transition: 'all 0.5s ease-in 0s', [theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'all 0.5s ease-in 0s',
},
height: '100%', height: '100%',
}), }),
cardError: css({ cardError: css({

View File

@ -1,6 +1,8 @@
import { css, keyframes } from '@emotion/css'; import { css, keyframes } from '@emotion/css';
import React from 'react'; import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes'; import { useStyles2 } from '../../themes';
export const EllipsisAnimated = React.memo(() => { export const EllipsisAnimated = React.memo(() => {
@ -16,19 +18,25 @@ export const EllipsisAnimated = React.memo(() => {
EllipsisAnimated.displayName = 'EllipsisAnimated'; EllipsisAnimated.displayName = 'EllipsisAnimated';
const getStyles = () => { const getStyles = (theme: GrafanaTheme2) => {
return { return {
ellipsis: css({ ellipsis: css({
display: 'inline', display: 'inline',
}), }),
firstDot: css({ firstDot: css({
animation: `${firstDot} 2s linear infinite`, [theme.transitions.handleMotion('no-preference', 'reduce')]: {
animation: `${firstDot} 2s linear infinite`,
},
}), }),
secondDot: css({ secondDot: css({
animation: `${secondDot} 2s linear infinite`, [theme.transitions.handleMotion('no-preference', 'reduce')]: {
animation: `${secondDot} 2s linear infinite`,
},
}), }),
thirdDot: css({ thirdDot: css({
animation: `${thirdDot} 2s linear infinite`, [theme.transitions.handleMotion('no-preference', 'reduce')]: {
animation: `${thirdDot} 2s linear infinite`,
},
}), }),
}; };
}; };

View File

@ -120,7 +120,9 @@ const getLabelStyles = (theme: GrafanaTheme2) => ({
fontWeight: theme.typography.fontWeightMedium, fontWeight: theme.typography.fontWeightMedium,
backgroundColor: theme.colors.primary.shade, backgroundColor: theme.colors.primary.shade,
color: theme.colors.text.primary, color: theme.colors.text.primary,
animation: 'pulse 3s ease-out 0s infinite normal forwards', [theme.transitions.handleMotion('no-preference', 'reduce')]: {
animation: 'pulse 3s ease-out 0s infinite normal forwards',
},
'@keyframes pulse': { '@keyframes pulse': {
'0%': { '0%': {
color: theme.colors.text.primary, color: theme.colors.text.primary,

View File

@ -94,9 +94,11 @@ export const getCardContainerStyles = (
borderRadius: theme.shape.radius.default, borderRadius: theme.shape.radius.default,
marginBottom: '8px', marginBottom: '8px',
pointerEvents: disabled ? 'none' : 'auto', pointerEvents: disabled ? 'none' : 'auto',
transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { [theme.transitions.handleMotion('no-preference', 'reduce')]: {
duration: theme.transitions.duration.short, transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
}), duration: theme.transitions.duration.short,
}),
},
...(!disableHover && { ...(!disableHover && {
'&:hover': { '&:hover': {
@ -123,9 +125,11 @@ export const getCardContainerStyles = (
position: 'relative', position: 'relative',
pointerEvents: disabled ? 'none' : 'auto', pointerEvents: disabled ? 'none' : 'auto',
marginBottom: theme.spacing(1), marginBottom: theme.spacing(1),
transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { [theme.transitions.handleMotion('no-preference', 'reduce')]: {
duration: theme.transitions.duration.short, transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
}), duration: theme.transitions.duration.short,
}),
},
...(!disableHover && { ...(!disableHover && {
'&:hover': { '&:hover': {

View File

@ -71,7 +71,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
label: 'collapse__header', label: 'collapse__header',
padding: theme.spacing(1, 2, 1, 2), padding: theme.spacing(1, 2, 1, 2),
display: 'flex', display: 'flex',
transition: 'all 0.1s linear',
}), }),
headerCollapsed: css({ headerCollapsed: css({
label: 'collapse__header--collapsed', label: 'collapse__header--collapsed',

View File

@ -80,9 +80,11 @@ const getStyles = (
boxShadow: isSelected boxShadow: isSelected
? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${theme.colors.getContrastText(color)}` ? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${theme.colors.getContrastText(color)}`
: 'none', : 'none',
transition: theme.transitions.create(['transform'], { [theme.transitions.handleMotion('no-preference')]: {
duration: theme.transitions.duration.short, transition: theme.transitions.create(['transform'], {
}), duration: theme.transitions.duration.short,
}),
},
'&:hover': { '&:hover': {
transform: 'scale(1.1)', transform: 'scale(1.1)',
}, },

View File

@ -133,18 +133,22 @@ const getStyles = (theme: GrafanaTheme2) => {
}), }),
mainButton: css({ mainButton: css({
opacity: 1, opacity: 1,
transition: theme.transitions.create(['opacity'], { [theme.transitions.handleMotion('no-preference')]: {
duration: theme.transitions.duration.shortest, transition: theme.transitions.create(['opacity'], {
easing: theme.transitions.easing.easeOut, duration: theme.transitions.duration.shortest,
}), easing: theme.transitions.easing.easeOut,
}),
},
zIndex: 2, zIndex: 2,
}), }),
mainButtonHide: css({ mainButtonHide: css({
opacity: 0, opacity: 0,
transition: theme.transitions.create(['opacity', 'visibility'], { [theme.transitions.handleMotion('no-preference')]: {
duration: theme.transitions.duration.shortest, transition: theme.transitions.create(['opacity', 'visibility'], {
easing: theme.transitions.easing.easeIn, duration: theme.transitions.duration.shortest,
}), easing: theme.transitions.easing.easeIn,
}),
},
visibility: 'hidden', visibility: 'hidden',
zIndex: 0, zIndex: 0,
}), }),
@ -164,19 +168,23 @@ const getStyles = (theme: GrafanaTheme2) => {
display: 'flex', display: 'flex',
opacity: 1, opacity: 1,
transform: 'translateX(0)', transform: 'translateX(0)',
transition: theme.transitions.create(['opacity', 'transform'], { [theme.transitions.handleMotion('no-preference')]: {
duration: theme.transitions.duration.shortest, transition: theme.transitions.create(['opacity', 'transform'], {
easing: theme.transitions.easing.easeOut, duration: theme.transitions.duration.shortest,
}), easing: theme.transitions.easing.easeOut,
}),
},
zIndex: 1, zIndex: 1,
}), }),
confirmButtonHide: css({ confirmButtonHide: css({
opacity: 0, opacity: 0,
transform: 'translateX(100%)', transform: 'translateX(100%)',
transition: theme.transitions.create(['opacity', 'transform', 'visibility'], { [theme.transitions.handleMotion('no-preference')]: {
duration: theme.transitions.duration.shortest, transition: theme.transitions.create(['opacity', 'transform', 'visibility'], {
easing: theme.transitions.easing.easeIn, duration: theme.transitions.duration.shortest,
}), easing: theme.transitions.easing.easeIn,
}),
},
visibility: 'hidden', visibility: 'hidden',
}), }),
}; };

View File

@ -65,7 +65,9 @@ const getStyles = (theme: GrafanaTheme2) => {
pointerEvents: 'none', pointerEvents: 'none',
position: 'absolute', position: 'absolute',
right: 0, right: 0,
transition: theme.transitions.create('opacity'), [theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create('opacity'),
},
zIndex: 1, zIndex: 1,
}), }),
scrollTopIndicator: css({ scrollTopIndicator: css({

View File

@ -13,7 +13,10 @@ import {
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { CSSTransition } from 'react-transition-group'; import { CSSTransition } from 'react-transition-group';
import { ReactUtils, handleReducedMotion } from '../../utils'; import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes';
import { ReactUtils } from '../../utils';
import { getPlacement } from '../../utils/tooltipUtils'; import { getPlacement } from '../../utils/tooltipUtils';
import { Portal } from '../Portal/Portal'; import { Portal } from '../Portal/Portal';
import { TooltipPlacement } from '../Tooltip/types'; import { TooltipPlacement } from '../Tooltip/types';
@ -63,7 +66,7 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi
const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]);
const animationDuration = 150; const animationDuration = 150;
const animationStyles = getStyles(animationDuration); const animationStyles = useStyles2(getStyles, animationDuration);
const onOverlayClicked = () => { const onOverlayClicked = () => {
setShow(false); setShow(false);
@ -109,22 +112,22 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi
Dropdown.displayName = 'Dropdown'; Dropdown.displayName = 'Dropdown';
const getStyles = (duration: number) => { const getStyles = (theme: GrafanaTheme2, duration: number) => {
return { return {
appear: css({ appear: css({
opacity: '0', opacity: '0',
position: 'relative', position: 'relative',
transformOrigin: 'top', transformOrigin: 'top',
...handleReducedMotion({ [theme.transitions.handleMotion('no-preference')]: {
transform: 'scaleY(0.5)', transform: 'scaleY(0.5)',
}), },
}), }),
appearActive: css({ appearActive: css({
opacity: '1', opacity: '1',
...handleReducedMotion({ [theme.transitions.handleMotion('no-preference')]: {
transform: 'scaleY(1)', transform: 'scaleY(1)',
transition: `transform ${duration}ms cubic-bezier(0.2, 0, 0.2, 1), opacity ${duration}ms cubic-bezier(0.2, 0, 0.2, 1)`, transition: `transform ${duration}ms cubic-bezier(0.2, 0, 0.2, 1), opacity ${duration}ms cubic-bezier(0.2, 0, 0.2, 1)`,
}), },
}), }),
}; };
}; };

View File

@ -7,7 +7,6 @@ import { useStyles2 } from '../../themes';
import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins'; import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins';
import { ComponentSize } from '../../types'; import { ComponentSize } from '../../types';
import { IconName, IconSize, IconType } from '../../types/icon'; import { IconName, IconSize, IconType } from '../../types/icon';
import { handleReducedMotion } from '../../utils/handleReducedMotion';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { getSvgSize } from '../Icon/utils'; import { getSvgSize } from '../Icon/utils';
import { TooltipPlacement, PopoverContent, Tooltip } from '../Tooltip'; import { TooltipPlacement, PopoverContent, Tooltip } from '../Tooltip';
@ -144,11 +143,11 @@ const getStyles = (theme: GrafanaTheme2, size: IconSize, variant: IconButtonVari
height: `${hoverSize}px`, height: `${hoverSize}px`,
borderRadius: theme.shape.radius.default, borderRadius: theme.shape.radius.default,
content: '""', content: '""',
...handleReducedMotion({ [theme.transitions.handleMotion('no-preference', 'reduce')]: {
transitionDuration: '0.2s', transitionDuration: '0.2s',
transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)',
transitionProperty: 'opacity', transitionProperty: 'opacity',
}), },
}, },
'&:focus, &:focus-visible': getFocusStyles(theme), '&:focus, &:focus-visible': getFocusStyles(theme),

View File

@ -4,7 +4,6 @@ import React, { CSSProperties } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes'; import { useStyles2 } from '../../themes';
import { handleReducedMotion } from '../../utils/handleReducedMotion';
export interface LoadingBarProps { export interface LoadingBarProps {
width: number; width: number;
@ -33,7 +32,7 @@ export function LoadingBar({ width, delay = DEFAULT_ANIMATION_DELAY, ariaLabel =
); );
} }
const getStyles = (_theme: GrafanaTheme2, delay: number, duration: number) => { const getStyles = (theme: GrafanaTheme2, delay: number, duration: number) => {
const animation = keyframes({ const animation = keyframes({
'0%': { '0%': {
transform: 'translateX(-100%)', transform: 'translateX(-100%)',
@ -50,20 +49,23 @@ const getStyles = (_theme: GrafanaTheme2, delay: number, duration: number) => {
height: 1, height: 1,
background: 'linear-gradient(90deg, rgba(110, 159, 255, 0) 0%, #6E9FFF 80.75%, rgba(110, 159, 255, 0) 100%)', background: 'linear-gradient(90deg, rgba(110, 159, 255, 0) 0%, #6E9FFF 80.75%, rgba(110, 159, 255, 0) 100%)',
transform: 'translateX(-100%)', transform: 'translateX(-100%)',
animationName: animation,
// an initial delay to prevent the loader from showing if the response is faster than the delay
animationDelay: `${delay}ms`,
animationTimingFunction: 'linear',
animationIterationCount: 'infinite',
willChange: 'transform', willChange: 'transform',
...handleReducedMotion( [theme.transitions.handleMotion('no-preference')]: {
{ animationName: animation,
animationDuration: `${duration}ms`, // an initial delay to prevent the loader from showing if the response is faster than the delay
}, animationDelay: `${delay}ms`,
{ animationTimingFunction: 'linear',
animationDuration: `${4 * duration}ms`, animationIterationCount: 'infinite',
} animationDuration: `${duration}ms`,
), },
[theme.transitions.handleMotion('reduce')]: {
animationName: animation,
// an initial delay to prevent the loader from showing if the response is faster than the delay
animationDelay: `${delay}ms`,
animationTimingFunction: 'linear',
animationIterationCount: 'infinite',
animationDuration: `${4 * duration}ms`,
},
}), }),
}; };
}; };

View File

@ -66,7 +66,9 @@ function getStyles(theme: GrafanaTheme2) {
return { return {
container: css({ container: css({
label: 'hover-container-widget', label: 'hover-container-widget',
transition: `all .1s linear`, [theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: `all .1s linear`,
},
display: 'flex', display: 'flex',
position: 'absolute', position: 'absolute',
zIndex: 1, zIndex: 1,

View File

@ -263,7 +263,9 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
display: 'inline-block', display: 'inline-block',
background: resizerColor, background: resizerColor,
opacity: 0, opacity: 0,
transition: 'opacity 0.2s ease-in-out', [theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 0.2s ease-in-out',
},
width: '8px', width: '8px',
height: '100%', height: '100%',
position: 'absolute', position: 'absolute',

View File

@ -152,9 +152,11 @@ const getStyles = (theme: GrafanaTheme2) => {
fontWeight: theme.typography.fontWeightMedium, fontWeight: theme.typography.fontWeightMedium,
border: `1px solid ${theme.colors.secondary.border}`, border: `1px solid ${theme.colors.secondary.border}`,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
transition: theme.transitions.create(['background', 'box-shadow', 'border-color', 'color'], { [theme.transitions.handleMotion('no-preference', 'reduce')]: {
duration: theme.transitions.duration.short, transition: theme.transitions.create(['background', 'box-shadow', 'border-color', 'color'], {
}), duration: theme.transitions.duration.short,
}),
},
'&:focus, &:focus-visible': { '&:focus, &:focus-visible': {
...getFocusStyles(theme), ...getFocusStyles(theme),

View File

@ -36,8 +36,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'block', display: 'block',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
cursor: 'pointer', cursor: 'pointer',
transition: [theme.transitions.handleMotion('no-preference', 'reduce')]: {
'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)', 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)',
},
}), }),
typeaheadItemSelected: css({ typeaheadItemSelected: css({

View File

@ -23,7 +23,7 @@ export function FadeTransition(props: Props) {
); );
} }
const getStyles = (_theme: GrafanaTheme2, duration: number) => ({ const getStyles = (theme: GrafanaTheme2, duration: number) => ({
enter: css({ enter: css({
label: 'enter', label: 'enter',
opacity: 0, opacity: 0,
@ -31,7 +31,9 @@ const getStyles = (_theme: GrafanaTheme2, duration: number) => ({
enterActive: css({ enterActive: css({
label: 'enterActive', label: 'enterActive',
opacity: 1, opacity: 1,
transition: `opacity ${duration}ms ease-out`, [theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: `opacity ${duration}ms ease-out`,
},
}), }),
exit: css({ exit: css({
label: 'exit', label: 'exit',
@ -40,6 +42,8 @@ const getStyles = (_theme: GrafanaTheme2, duration: number) => ({
exitActive: css({ exitActive: css({
label: 'exitActive', label: 'exitActive',
opacity: 0, opacity: 0,
transition: `opacity ${duration}ms ease-out`, [theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: `opacity ${duration}ms ease-out`,
},
}), }),
}); });

View File

@ -26,7 +26,7 @@ export function SlideOutTransition(props: Props) {
); );
} }
const getStyles = (_theme: GrafanaTheme2, duration: number, measurement: 'width' | 'height', size: number) => ({ const getStyles = (theme: GrafanaTheme2, duration: number, measurement: 'width' | 'height', size: number) => ({
enter: css({ enter: css({
label: 'enter', label: 'enter',
[`${measurement}`]: 0, [`${measurement}`]: 0,
@ -36,7 +36,12 @@ const getStyles = (_theme: GrafanaTheme2, duration: number, measurement: 'width'
label: 'enterActive', label: 'enterActive',
[`${measurement}`]: `${size}px`, [`${measurement}`]: `${size}px`,
opacity: 1, opacity: 1,
transition: `opacity ${duration}ms ease-out, ${measurement} ${duration}ms ease-out`, [theme.transitions.handleMotion('no-preference')]: {
transition: `opacity ${duration}ms ease-out, ${measurement} ${duration}ms ease-out`,
},
[theme.transitions.handleMotion('reduce')]: {
transition: `opacity ${duration}ms ease-out`,
},
}), }),
exit: css({ exit: css({
label: 'exit', label: 'exit',
@ -47,6 +52,11 @@ const getStyles = (_theme: GrafanaTheme2, duration: number, measurement: 'width'
label: 'exitActive', label: 'exitActive',
opacity: 0, opacity: 0,
[`${measurement}`]: 0, [`${measurement}`]: 0,
transition: `opacity ${duration}ms ease-out, ${measurement} ${duration}ms ease-out`, [theme.transitions.handleMotion('no-preference')]: {
transition: `opacity ${duration}ms ease-out, ${measurement} ${duration}ms ease-out`,
},
[theme.transitions.handleMotion('reduce')]: {
transition: `opacity ${duration}ms ease-out`,
},
}), }),
}); });

View File

@ -1,16 +0,0 @@
import { CSSInterpolation } from '@emotion/css';
/**
* @param styles - Styles to apply when no `prefers-reduced-motion` preference is set.
* @param reducedMotionStyles - Styles to apply when `prefers-reduced-motion` is enabled.
* Applies one of `styles` or `reducedMotionStyles` depending on a users `prefers-reduced-motion` setting. Omitting `reducedMotionStyles` entirely will result in no styles being applied when `prefers-reduced-motion` is enabled. In most cases this is a reasonable default.
*/
export const handleReducedMotion = (styles: CSSInterpolation, reducedMotionStyles?: CSSInterpolation) => {
const result: Record<string, CSSInterpolation> = {
'@media (prefers-reduced-motion: no-preference)': styles,
};
if (reducedMotionStyles) {
result['@media (prefers-reduced-motion: reduce)'] = reducedMotionStyles;
}
return result;
};

View File

@ -19,6 +19,5 @@ export { createLogger } from './logger';
export { attachDebugger } from './debug'; export { attachDebugger } from './debug';
export * from './nodeGraph'; export * from './nodeGraph';
export { fuzzyMatch } from './fuzzy'; export { fuzzyMatch } from './fuzzy';
export { handleReducedMotion } from './handleReducedMotion';
export { ReactUtils }; export { ReactUtils };

View File

@ -37,7 +37,9 @@ export function buildTooltipTheme(
color: tooltipText, color: tooltipText,
fontSize: theme.typography.bodySmall.fontSize, fontSize: theme.typography.bodySmall.fontSize,
padding: theme.spacing(tooltipPadding.topBottom, tooltipPadding.rightLeft), padding: theme.spacing(tooltipPadding.topBottom, tooltipPadding.rightLeft),
transition: 'opacity 0.3s', [theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 0.3s',
},
zIndex: theme.zIndex.tooltip, zIndex: theme.zIndex.tooltip,
maxWidth: '400px', maxWidth: '400px',
overflowWrap: 'break-word', overflowWrap: 'break-word',

View File

@ -6,7 +6,7 @@ import React, { useRef } from 'react';
import CSSTransition from 'react-transition-group/CSSTransition'; import CSSTransition from 'react-transition-group/CSSTransition';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { handleReducedMotion, useStyles2, useTheme2 } from '@grafana/ui'; import { useStyles2, useTheme2 } from '@grafana/ui';
import { useGrafana } from 'app/core/context/GrafanaContext'; import { useGrafana } from 'app/core/context/GrafanaContext';
import { KioskMode } from 'app/types'; import { KioskMode } from 'app/types';
@ -125,10 +125,10 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => {
const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => {
const commonTransition = { const commonTransition = {
...handleReducedMotion({ [theme.transitions.handleMotion('no-preference')]: {
transitionDuration: `${animationDuration}ms`, transitionDuration: `${animationDuration}ms`,
transitionTimingFunction: theme.transitions.easing.easeInOut, transitionTimingFunction: theme.transitions.easing.easeInOut,
}), },
[theme.breakpoints.down('md')]: { [theme.breakpoints.down('md')]: {
overflow: 'hidden', overflow: 'hidden',
}, },

View File

@ -33,6 +33,18 @@ const fadeIn = keyframes({
}, },
}); });
const pulse = keyframes({
'0%': {
opacity: 0,
},
'50%': {
opacity: 1,
},
'100%': {
opacity: 0,
},
});
const bounce = keyframes({ const bounce = keyframes({
'from, to': { 'from, to': {
transform: 'translateY(0px)', transform: 'translateY(0px)',
@ -70,25 +82,37 @@ const squash = keyframes({
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
container: css({ container: css({
opacity: 0, opacity: 0,
animationName: fadeIn, [theme.transitions.handleMotion('no-preference')]: {
animationIterationCount: 1, animationName: fadeIn,
animationDuration: '0.9s', animationIterationCount: 1,
animationDelay: '0.5s', animationDuration: '0.9s',
animationFillMode: 'forwards', animationDelay: '0.5s',
animationFillMode: 'forwards',
},
[theme.transitions.handleMotion('reduce')]: {
animationName: pulse,
animationIterationCount: 'infinite',
animationDuration: '4s',
animationDelay: '0.5s',
},
}), }),
bounce: css({ bounce: css({
textAlign: 'center', textAlign: 'center',
animationName: bounce, [theme.transitions.handleMotion('no-preference')]: {
animationDuration: '0.9s', animationName: bounce,
animationIterationCount: 'infinite', animationDuration: '0.9s',
animationIterationCount: 'infinite',
},
}), }),
logo: css({ logo: css({
display: 'inline-block', display: 'inline-block',
animationName: squash, [theme.transitions.handleMotion('no-preference')]: {
animationDuration: '0.9s', animationName: squash,
animationIterationCount: 'infinite', animationDuration: '0.9s',
animationIterationCount: 'infinite',
},
width: '60px', width: '60px',
height: '60px', height: '60px',
}), }),

View File

@ -2,7 +2,7 @@ import { cx, css, keyframes } from '@emotion/css';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { handleReducedMotion, useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { Branding } from '../Branding/Branding'; import { Branding } from '../Branding/Branding';
import { BrandingSettings } from '../Branding/types'; import { BrandingSettings } from '../Branding/types';
@ -148,7 +148,9 @@ export const getLoginStyles = (theme: GrafanaTheme2) => {
borderRadius: theme.shape.radius.default, borderRadius: theme.shape.radius.default,
padding: theme.spacing(2, 0), padding: theme.spacing(2, 0),
opacity: 0, opacity: 0,
transition: 'opacity 0.5s ease-in-out', [theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'opacity 0.5s ease-in-out',
},
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up('sm')]: {
minHeight: theme.spacing(40), minHeight: theme.spacing(40),
@ -171,12 +173,14 @@ export const getLoginStyles = (theme: GrafanaTheme2) => {
maxWidth: 415, maxWidth: 415,
width: '100%', width: '100%',
transform: 'translate(0px, 0px)', transform: 'translate(0px, 0px)',
transition: '0.25s ease', [theme.transitions.handleMotion('no-preference')]: {
transition: '0.25s ease',
},
}), }),
enterAnimation: css({ enterAnimation: css({
...handleReducedMotion({ [theme.transitions.handleMotion('no-preference')]: {
animation: `${flyInAnimation} ease-out 0.2s`, animation: `${flyInAnimation} ease-out 0.2s`,
}), },
}), }),
}; };
}; };

View File

@ -1,4 +1,4 @@
import { css, cx } from '@emotion/css'; import { css, cx, keyframes } from '@emotion/css';
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { useFormContext, Controller } from 'react-hook-form'; import { useFormContext, Controller } from 'react-hook-form';
@ -149,6 +149,15 @@ function LinkToContactPoints() {
); );
} }
const rotation = keyframes({
from: {
transform: 'rotate(720deg)',
},
to: {
transform: 'rotate(0deg)',
},
});
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
contactPointsSelector: css({ contactPointsSelector: css({
display: 'flex', display: 'flex',
@ -172,14 +181,11 @@ const getStyles = (theme: GrafanaTheme2) => ({
}), }),
loading: css({ loading: css({
pointerEvents: 'none', pointerEvents: 'none',
animation: 'rotation 2s infinite linear', [theme.transitions.handleMotion('no-preference')]: {
'@keyframes rotation': { animation: `${rotation} 2s infinite linear`,
from: { },
transform: 'rotate(720deg)', [theme.transitions.handleMotion('reduce')]: {
}, animation: `${rotation} 6s infinite linear`,
to: {
transform: 'rotate(0deg)',
},
}, },
}), }),
warn: css({ warn: css({

View File

@ -122,6 +122,8 @@ export const droneFrontItem: CanvasElementItem = {
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
droneFront: css({ droneFront: css({
// TODO: figure out what styles to apply when prefers-reduced-motion is set
// eslint-disable-next-line @grafana/no-unreduced-motion
transition: 'transform 0.4s', transition: 'transform 0.4s',
}), }),
}); });

View File

@ -121,6 +121,8 @@ export const droneSideItem: CanvasElementItem = {
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
droneSide: css({ droneSide: css({
// TODO: figure out what styles to apply when prefers-reduced-motion is set
// eslint-disable-next-line @grafana/no-unreduced-motion
transition: 'transform 0.4s', transition: 'transform 0.4s',
}), }),
}); });

View File

@ -180,9 +180,13 @@ const getStyles = (theme: GrafanaTheme2) => ({
}, },
}), }),
propellerCW: css({ propellerCW: css({
// TODO: figure out what styles to apply when prefers-reduced-motion is set
// eslint-disable-next-line @grafana/no-unreduced-motion
animationDirection: 'normal', animationDirection: 'normal',
}), }),
propellerCCW: css({ propellerCCW: css({
// TODO: figure out what styles to apply when prefers-reduced-motion is set
// eslint-disable-next-line @grafana/no-unreduced-motion
animationDirection: 'reverse', animationDirection: 'reverse',
}), }),
}); });

View File

@ -173,7 +173,9 @@ export const getServerStyles = (data: ServerData | undefined) => (theme: Grafana
fill: data?.statusColor ?? 'transparent', fill: data?.statusColor ?? 'transparent',
}), }),
circle: css({ circle: css({
animation: `blink ${data?.blinkRate ? 1 / data.blinkRate : 0}s infinite step-end`, [theme.transitions.handleMotion('no-preference', 'reduce')]: {
animation: `blink ${data?.blinkRate ? 1 / data.blinkRate : 0}s infinite step-end`,
},
fill: data?.bulbColor, fill: data?.bulbColor,
stroke: 'none', stroke: 'none',
}), }),

View File

@ -90,7 +90,9 @@ const getStyles = (theme: GrafanaTheme2) => {
fontSize: theme.typography.fontSize, fontSize: theme.typography.fontSize,
fontWeight: theme.typography.fontWeightMedium, fontWeight: theme.typography.fontWeightMedium,
paddingLeft: `${theme.spacing(1)}`, paddingLeft: `${theme.spacing(1)}`,
transition: 'background-color 0.1s ease-in-out', [theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'background-color 0.1s ease-in-out',
},
cursor: 'move', cursor: 'move',
'&:hover': { '&:hover': {
@ -102,7 +104,9 @@ const getStyles = (theme: GrafanaTheme2) => {
outline: '2px dotted transparent', outline: '2px dotted transparent',
outlineOffset: '2px', outlineOffset: '2px',
boxShadow: '0 0 0 2px black, 0 0 0px 4px #1f60c4', boxShadow: '0 0 0 2px black, 0 0 0px 4px #1f60c4',
animation: `${pulsate} 2s ease infinite`, [theme.transitions.handleMotion('no-preference', 'reduce')]: {
animation: `${pulsate} 2s ease infinite`,
},
}), }),
}; };
}; };

View File

@ -50,7 +50,9 @@ export const getStyles = (theme: GrafanaTheme2) => {
opacity: '0%', opacity: '0%',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
animation: `${invisibleToVisible} 0s step-end ${slowStartThreshold} 1 normal forwards`, [theme.transitions.handleMotion('no-preference', 'reduce')]: {
animation: `${invisibleToVisible} 0s step-end ${slowStartThreshold} 1 normal forwards`,
},
}), }),
dashboardLoadingText: css({ dashboardLoadingText: css({
fontSize: theme.typography.h4.fontSize, fontSize: theme.typography.h4.fontSize,

View File

@ -68,6 +68,8 @@ const getStyles = (theme: GrafanaTheme2) => ({
}), }),
drawerActive: css({ drawerActive: css({
opacity: 1, opacity: 1,
animation: `0.5s ease-out ${drawerSlide(theme)}`, [theme.transitions.handleMotion('no-preference')]: {
animation: `0.5s ease-out ${drawerSlide(theme)}`,
},
}), }),
}); });

View File

@ -567,7 +567,9 @@ function getStyles(theme: GrafanaTheme2, height: number, width: number) {
}), }),
rzHandle: css({ rzHandle: css({
background: theme.colors.secondary.main, background: theme.colors.secondary.main,
transition: '0.3s background ease-in-out', [theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: '0.3s background ease-in-out',
},
position: 'relative', position: 'relative',
height: '50% !important', height: '50% !important',
width: `${theme.spacing(1)} !important`, width: `${theme.spacing(1)} !important`,

View File

@ -138,9 +138,11 @@ const getStyles = (theme: GrafanaTheme2) => {
padding: theme.spacing(1), padding: theme.spacing(1),
width: '100%', width: '100%',
overflow: 'hidden', overflow: 'hidden',
transition: theme.transitions.create(['background'], { [theme.transitions.handleMotion('no-preference', 'reduce')]: {
duration: theme.transitions.duration.short, transition: theme.transitions.create(['background'], {
}), duration: theme.transitions.duration.short,
}),
},
'&:hover': { '&:hover': {
background: theme.colors.emphasize(theme.colors.background.secondary, 0.03), background: theme.colors.emphasize(theme.colors.background.secondary, 0.03),

View File

@ -100,9 +100,11 @@ export const getStyles = (theme: GrafanaTheme2) => {
background: theme.colors.background.secondary, background: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default, borderRadius: theme.shape.radius.default,
padding: theme.spacing(3), padding: theme.spacing(3),
transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { [theme.transitions.handleMotion('no-preference', 'reduce')]: {
duration: theme.transitions.duration.short, transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
}), duration: theme.transitions.duration.short,
}),
},
'&:hover': { '&:hover': {
background: theme.colors.emphasize(theme.colors.background.secondary, 0.03), background: theme.colors.emphasize(theme.colors.background.secondary, 0.03),

View File

@ -307,7 +307,9 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme2) => {
marble: css({ marble: css({
display: 'block', display: 'block',
opacity: 0.5, opacity: 0.5,
transition: 'transform 0.15s ease-out', [theme.transitions.handleMotion('no-preference')]: {
transition: 'transform 0.15s ease-out',
},
}), }),
activeMarble: css({ activeMarble: css({
transform: 'scale(1.3)', transform: 'scale(1.3)',