diff --git a/.eslintrc b/.eslintrc index c61459edfad..71d0b6fbcac 100644 --- a/.eslintrc +++ b/.eslintrc @@ -8,6 +8,7 @@ }, "rules": { "@grafana/no-border-radius-literal": "error", + "@grafana/no-unreduced-motion": "error", "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 "react/no-unknown-property": ["error", { "ignore": ["css"] }], diff --git a/packages/grafana-data/src/themes/createTransitions.ts b/packages/grafana-data/src/themes/createTransitions.ts index d15b821aba5..723978b1741 100644 --- a/packages/grafana-data/src/themes/createTransitions.ts +++ b/packages/grafana-data/src/themes/createTransitions.ts @@ -53,6 +53,12 @@ export function create(props: string | string[] = ['all'], options: CreateTransi .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) { if (!height) { return 0; @@ -74,6 +80,7 @@ export interface ThemeTransitions { duration: typeof duration; easing: typeof easing; getAutoHeightDuration: typeof getAutoHeightDuration; + handleMotion: typeof handleMotion; } /** @internal */ @@ -83,5 +90,6 @@ export function createTransitions(): ThemeTransitions { duration, easing, getAutoHeightDuration, + handleMotion, }; } diff --git a/packages/grafana-eslint-rules/README.md b/packages/grafana-eslint-rules/README.md index 187e1d2deb9..1fb043a4ec0 100644 --- a/packages/grafana-eslint-rules/README.md +++ b/packages/grafana-eslint-rules/README.md @@ -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. -`@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` diff --git a/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs b/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs index a8f673a4be2..a1b279fb861 100644 --- a/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs +++ b/packages/grafana-eslint-rules/rules/no-unreduced-motion.cjs @@ -55,7 +55,7 @@ const rule = createRule({ description: 'Check if animation or transition properties are used directly.', }, 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: [], }, diff --git a/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx b/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx index 405b40b7cd2..a2d23aacdc9 100644 --- a/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx +++ b/packages/grafana-prometheus/src/querybuilder/shared/OperationEditor.tsx @@ -238,7 +238,9 @@ const getStyles = (theme: GrafanaTheme2) => { borderRadius: theme.shape.radius.default, marginBottom: theme.spacing(1), position: 'relative', - transition: 'all 0.5s ease-in 0s', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'all 0.5s ease-in 0s', + }, height: '100%', }), cardError: css({ diff --git a/packages/grafana-ui/src/components/AutoSaveField/EllipsisAnimated.tsx b/packages/grafana-ui/src/components/AutoSaveField/EllipsisAnimated.tsx index 1a91db3120d..bac0fe4445d 100644 --- a/packages/grafana-ui/src/components/AutoSaveField/EllipsisAnimated.tsx +++ b/packages/grafana-ui/src/components/AutoSaveField/EllipsisAnimated.tsx @@ -1,6 +1,8 @@ import { css, keyframes } from '@emotion/css'; import React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; + import { useStyles2 } from '../../themes'; export const EllipsisAnimated = React.memo(() => { @@ -16,19 +18,25 @@ export const EllipsisAnimated = React.memo(() => { EllipsisAnimated.displayName = 'EllipsisAnimated'; -const getStyles = () => { +const getStyles = (theme: GrafanaTheme2) => { return { ellipsis: css({ display: 'inline', }), firstDot: css({ - animation: `${firstDot} 2s linear infinite`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + animation: `${firstDot} 2s linear infinite`, + }, }), secondDot: css({ - animation: `${secondDot} 2s linear infinite`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + animation: `${secondDot} 2s linear infinite`, + }, }), thirdDot: css({ - animation: `${thirdDot} 2s linear infinite`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + animation: `${thirdDot} 2s linear infinite`, + }, }), }; }; diff --git a/packages/grafana-ui/src/components/BrowserLabel/Label.tsx b/packages/grafana-ui/src/components/BrowserLabel/Label.tsx index 1b83a57bc26..69d09b4aafd 100644 --- a/packages/grafana-ui/src/components/BrowserLabel/Label.tsx +++ b/packages/grafana-ui/src/components/BrowserLabel/Label.tsx @@ -120,7 +120,9 @@ const getLabelStyles = (theme: GrafanaTheme2) => ({ fontWeight: theme.typography.fontWeightMedium, backgroundColor: theme.colors.primary.shade, 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': { '0%': { color: theme.colors.text.primary, diff --git a/packages/grafana-ui/src/components/Card/CardContainer.tsx b/packages/grafana-ui/src/components/Card/CardContainer.tsx index 8879a87ed4f..15ba01c9651 100644 --- a/packages/grafana-ui/src/components/Card/CardContainer.tsx +++ b/packages/grafana-ui/src/components/Card/CardContainer.tsx @@ -94,9 +94,11 @@ export const getCardContainerStyles = ( borderRadius: theme.shape.radius.default, marginBottom: '8px', pointerEvents: disabled ? 'none' : 'auto', - transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), + }, ...(!disableHover && { '&:hover': { @@ -123,9 +125,11 @@ export const getCardContainerStyles = ( position: 'relative', pointerEvents: disabled ? 'none' : 'auto', marginBottom: theme.spacing(1), - transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), + }, ...(!disableHover && { '&:hover': { diff --git a/packages/grafana-ui/src/components/Collapse/Collapse.tsx b/packages/grafana-ui/src/components/Collapse/Collapse.tsx index a1526af6154..d934ee52285 100644 --- a/packages/grafana-ui/src/components/Collapse/Collapse.tsx +++ b/packages/grafana-ui/src/components/Collapse/Collapse.tsx @@ -71,7 +71,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ label: 'collapse__header', padding: theme.spacing(1, 2, 1, 2), display: 'flex', - transition: 'all 0.1s linear', }), headerCollapsed: css({ label: 'collapse__header--collapsed', diff --git a/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx b/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx index f3827df404e..97cdabdc1c1 100644 --- a/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx +++ b/packages/grafana-ui/src/components/ColorPicker/ColorSwatch.tsx @@ -80,9 +80,11 @@ const getStyles = ( boxShadow: isSelected ? `inset 0 0 0 2px ${color}, inset 0 0 0 4px ${theme.colors.getContrastText(color)}` : 'none', - transition: theme.transitions.create(['transform'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference')]: { + transition: theme.transitions.create(['transform'], { + duration: theme.transitions.duration.short, + }), + }, '&:hover': { transform: 'scale(1.1)', }, diff --git a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx index 850da68b49e..d8c539fc43c 100644 --- a/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx +++ b/packages/grafana-ui/src/components/ConfirmButton/ConfirmButton.tsx @@ -133,18 +133,22 @@ const getStyles = (theme: GrafanaTheme2) => { }), mainButton: css({ opacity: 1, - transition: theme.transitions.create(['opacity'], { - duration: theme.transitions.duration.shortest, - easing: theme.transitions.easing.easeOut, - }), + [theme.transitions.handleMotion('no-preference')]: { + transition: theme.transitions.create(['opacity'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeOut, + }), + }, zIndex: 2, }), mainButtonHide: css({ opacity: 0, - transition: theme.transitions.create(['opacity', 'visibility'], { - duration: theme.transitions.duration.shortest, - easing: theme.transitions.easing.easeIn, - }), + [theme.transitions.handleMotion('no-preference')]: { + transition: theme.transitions.create(['opacity', 'visibility'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeIn, + }), + }, visibility: 'hidden', zIndex: 0, }), @@ -164,19 +168,23 @@ const getStyles = (theme: GrafanaTheme2) => { display: 'flex', opacity: 1, transform: 'translateX(0)', - transition: theme.transitions.create(['opacity', 'transform'], { - duration: theme.transitions.duration.shortest, - easing: theme.transitions.easing.easeOut, - }), + [theme.transitions.handleMotion('no-preference')]: { + transition: theme.transitions.create(['opacity', 'transform'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeOut, + }), + }, zIndex: 1, }), confirmButtonHide: css({ opacity: 0, transform: 'translateX(100%)', - transition: theme.transitions.create(['opacity', 'transform', 'visibility'], { - duration: theme.transitions.duration.shortest, - easing: theme.transitions.easing.easeIn, - }), + [theme.transitions.handleMotion('no-preference')]: { + transition: theme.transitions.create(['opacity', 'transform', 'visibility'], { + duration: theme.transitions.duration.shortest, + easing: theme.transitions.easing.easeIn, + }), + }, visibility: 'hidden', }), }; diff --git a/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx b/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx index b22711457ff..ef80d3df6a1 100644 --- a/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx +++ b/packages/grafana-ui/src/components/CustomScrollbar/ScrollIndicators.tsx @@ -65,7 +65,9 @@ const getStyles = (theme: GrafanaTheme2) => { pointerEvents: 'none', position: 'absolute', right: 0, - transition: theme.transitions.create('opacity'), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create('opacity'), + }, zIndex: 1, }), scrollTopIndicator: css({ diff --git a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx index f85465cba7f..94cfac03fb9 100644 --- a/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx +++ b/packages/grafana-ui/src/components/Dropdown/Dropdown.tsx @@ -13,7 +13,10 @@ import { import React, { useEffect, useRef, useState } from 'react'; 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 { Portal } from '../Portal/Portal'; 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 animationDuration = 150; - const animationStyles = getStyles(animationDuration); + const animationStyles = useStyles2(getStyles, animationDuration); const onOverlayClicked = () => { setShow(false); @@ -109,22 +112,22 @@ export const Dropdown = React.memo(({ children, overlay, placement, offset, onVi Dropdown.displayName = 'Dropdown'; -const getStyles = (duration: number) => { +const getStyles = (theme: GrafanaTheme2, duration: number) => { return { appear: css({ opacity: '0', position: 'relative', transformOrigin: 'top', - ...handleReducedMotion({ + [theme.transitions.handleMotion('no-preference')]: { transform: 'scaleY(0.5)', - }), + }, }), appearActive: css({ opacity: '1', - ...handleReducedMotion({ + [theme.transitions.handleMotion('no-preference')]: { 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)`, - }), + }, }), }; }; diff --git a/packages/grafana-ui/src/components/IconButton/IconButton.tsx b/packages/grafana-ui/src/components/IconButton/IconButton.tsx index fcfbb8f3d2d..b0cbcba5c4a 100644 --- a/packages/grafana-ui/src/components/IconButton/IconButton.tsx +++ b/packages/grafana-ui/src/components/IconButton/IconButton.tsx @@ -7,7 +7,6 @@ import { useStyles2 } from '../../themes'; import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins'; import { ComponentSize } from '../../types'; import { IconName, IconSize, IconType } from '../../types/icon'; -import { handleReducedMotion } from '../../utils/handleReducedMotion'; import { Icon } from '../Icon/Icon'; import { getSvgSize } from '../Icon/utils'; import { TooltipPlacement, PopoverContent, Tooltip } from '../Tooltip'; @@ -144,11 +143,11 @@ const getStyles = (theme: GrafanaTheme2, size: IconSize, variant: IconButtonVari height: `${hoverSize}px`, borderRadius: theme.shape.radius.default, content: '""', - ...handleReducedMotion({ + [theme.transitions.handleMotion('no-preference', 'reduce')]: { transitionDuration: '0.2s', transitionTimingFunction: 'cubic-bezier(0.4, 0, 0.2, 1)', transitionProperty: 'opacity', - }), + }, }, '&:focus, &:focus-visible': getFocusStyles(theme), diff --git a/packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx b/packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx index 07186be5e8c..29091d7bb36 100644 --- a/packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx +++ b/packages/grafana-ui/src/components/LoadingBar/LoadingBar.tsx @@ -4,7 +4,6 @@ import React, { CSSProperties } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../themes'; -import { handleReducedMotion } from '../../utils/handleReducedMotion'; export interface LoadingBarProps { 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({ '0%': { transform: 'translateX(-100%)', @@ -50,20 +49,23 @@ const getStyles = (_theme: GrafanaTheme2, delay: number, duration: number) => { height: 1, background: 'linear-gradient(90deg, rgba(110, 159, 255, 0) 0%, #6E9FFF 80.75%, rgba(110, 159, 255, 0) 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', - ...handleReducedMotion( - { - animationDuration: `${duration}ms`, - }, - { - animationDuration: `${4 * duration}ms`, - } - ), + [theme.transitions.handleMotion('no-preference')]: { + 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: `${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`, + }, }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx b/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx index 2bfe7ef5e20..b0fda6d1013 100644 --- a/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx @@ -66,7 +66,9 @@ function getStyles(theme: GrafanaTheme2) { return { container: css({ label: 'hover-container-widget', - transition: `all .1s linear`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: `all .1s linear`, + }, display: 'flex', position: 'absolute', zIndex: 1, diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index 9ef525ac005..a7647c978aa 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -263,7 +263,9 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell display: 'inline-block', background: resizerColor, opacity: 0, - transition: 'opacity 0.2s ease-in-out', + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: 'opacity 0.2s ease-in-out', + }, width: '8px', height: '100%', position: 'absolute', diff --git a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx index 9bebc40b83c..767ecd7d81b 100644 --- a/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx +++ b/packages/grafana-ui/src/components/ToolbarButton/ToolbarButton.tsx @@ -152,9 +152,11 @@ const getStyles = (theme: GrafanaTheme2) => { fontWeight: theme.typography.fontWeightMedium, border: `1px solid ${theme.colors.secondary.border}`, whiteSpace: 'nowrap', - transition: theme.transitions.create(['background', 'box-shadow', 'border-color', 'color'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background', 'box-shadow', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), + }, '&:focus, &:focus-visible': { ...getFocusStyles(theme), diff --git a/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx b/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx index b230f7f6cd7..de90392647b 100644 --- a/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx +++ b/packages/grafana-ui/src/components/Typeahead/TypeaheadItem.tsx @@ -36,8 +36,10 @@ const getStyles = (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', 'reduce')]: { + 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({ diff --git a/packages/grafana-ui/src/components/transitions/FadeTransition.tsx b/packages/grafana-ui/src/components/transitions/FadeTransition.tsx index c1d9d6be223..501a0dfe40e 100644 --- a/packages/grafana-ui/src/components/transitions/FadeTransition.tsx +++ b/packages/grafana-ui/src/components/transitions/FadeTransition.tsx @@ -23,7 +23,7 @@ export function FadeTransition(props: Props) { ); } -const getStyles = (_theme: GrafanaTheme2, duration: number) => ({ +const getStyles = (theme: GrafanaTheme2, duration: number) => ({ enter: css({ label: 'enter', opacity: 0, @@ -31,7 +31,9 @@ const getStyles = (_theme: GrafanaTheme2, duration: number) => ({ enterActive: css({ label: 'enterActive', opacity: 1, - transition: `opacity ${duration}ms ease-out`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: `opacity ${duration}ms ease-out`, + }, }), exit: css({ label: 'exit', @@ -40,6 +42,8 @@ const getStyles = (_theme: GrafanaTheme2, duration: number) => ({ exitActive: css({ label: 'exitActive', opacity: 0, - transition: `opacity ${duration}ms ease-out`, + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: `opacity ${duration}ms ease-out`, + }, }), }); diff --git a/packages/grafana-ui/src/components/transitions/SlideOutTransition.tsx b/packages/grafana-ui/src/components/transitions/SlideOutTransition.tsx index 5981f28d49e..a12a4ba1700 100644 --- a/packages/grafana-ui/src/components/transitions/SlideOutTransition.tsx +++ b/packages/grafana-ui/src/components/transitions/SlideOutTransition.tsx @@ -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({ label: 'enter', [`${measurement}`]: 0, @@ -36,7 +36,12 @@ const getStyles = (_theme: GrafanaTheme2, duration: number, measurement: 'width' label: 'enterActive', [`${measurement}`]: `${size}px`, 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({ label: 'exit', @@ -47,6 +52,11 @@ const getStyles = (_theme: GrafanaTheme2, duration: number, measurement: 'width' label: 'exitActive', opacity: 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`, + }, }), }); diff --git a/packages/grafana-ui/src/utils/handleReducedMotion.ts b/packages/grafana-ui/src/utils/handleReducedMotion.ts deleted file mode 100644 index 1ca85249dbe..00000000000 --- a/packages/grafana-ui/src/utils/handleReducedMotion.ts +++ /dev/null @@ -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 = { - '@media (prefers-reduced-motion: no-preference)': styles, - }; - if (reducedMotionStyles) { - result['@media (prefers-reduced-motion: reduce)'] = reducedMotionStyles; - } - return result; -}; diff --git a/packages/grafana-ui/src/utils/index.ts b/packages/grafana-ui/src/utils/index.ts index 55a1694e8b7..b4ec365d717 100644 --- a/packages/grafana-ui/src/utils/index.ts +++ b/packages/grafana-ui/src/utils/index.ts @@ -19,6 +19,5 @@ export { createLogger } from './logger'; export { attachDebugger } from './debug'; export * from './nodeGraph'; export { fuzzyMatch } from './fuzzy'; -export { handleReducedMotion } from './handleReducedMotion'; export { ReactUtils }; diff --git a/packages/grafana-ui/src/utils/tooltipUtils.ts b/packages/grafana-ui/src/utils/tooltipUtils.ts index b666ca85b29..821b9de5f7f 100644 --- a/packages/grafana-ui/src/utils/tooltipUtils.ts +++ b/packages/grafana-ui/src/utils/tooltipUtils.ts @@ -37,7 +37,9 @@ export function buildTooltipTheme( color: tooltipText, fontSize: theme.typography.bodySmall.fontSize, 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, maxWidth: '400px', overflowWrap: 'break-word', diff --git a/public/app/core/components/AppChrome/AppChromeMenu.tsx b/public/app/core/components/AppChrome/AppChromeMenu.tsx index 88b8db031ce..ee58fb39bc6 100644 --- a/public/app/core/components/AppChrome/AppChromeMenu.tsx +++ b/public/app/core/components/AppChrome/AppChromeMenu.tsx @@ -6,7 +6,7 @@ import React, { useRef } from 'react'; import CSSTransition from 'react-transition-group/CSSTransition'; 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 { KioskMode } from 'app/types'; @@ -125,10 +125,10 @@ const getStyles = (theme: GrafanaTheme2, searchBarHidden?: boolean) => { const getAnimStyles = (theme: GrafanaTheme2, animationDuration: number) => { const commonTransition = { - ...handleReducedMotion({ + [theme.transitions.handleMotion('no-preference')]: { transitionDuration: `${animationDuration}ms`, transitionTimingFunction: theme.transitions.easing.easeInOut, - }), + }, [theme.breakpoints.down('md')]: { overflow: 'hidden', }, diff --git a/public/app/core/components/BouncingLoader/BouncingLoader.tsx b/public/app/core/components/BouncingLoader/BouncingLoader.tsx index 21e76010532..e3273663af4 100644 --- a/public/app/core/components/BouncingLoader/BouncingLoader.tsx +++ b/public/app/core/components/BouncingLoader/BouncingLoader.tsx @@ -33,6 +33,18 @@ const fadeIn = keyframes({ }, }); +const pulse = keyframes({ + '0%': { + opacity: 0, + }, + '50%': { + opacity: 1, + }, + '100%': { + opacity: 0, + }, +}); + const bounce = keyframes({ 'from, to': { transform: 'translateY(0px)', @@ -70,25 +82,37 @@ const squash = keyframes({ const getStyles = (theme: GrafanaTheme2) => ({ container: css({ opacity: 0, - animationName: fadeIn, - animationIterationCount: 1, - animationDuration: '0.9s', - animationDelay: '0.5s', - animationFillMode: 'forwards', + [theme.transitions.handleMotion('no-preference')]: { + animationName: fadeIn, + animationIterationCount: 1, + animationDuration: '0.9s', + animationDelay: '0.5s', + animationFillMode: 'forwards', + }, + [theme.transitions.handleMotion('reduce')]: { + animationName: pulse, + animationIterationCount: 'infinite', + animationDuration: '4s', + animationDelay: '0.5s', + }, }), bounce: css({ textAlign: 'center', - animationName: bounce, - animationDuration: '0.9s', - animationIterationCount: 'infinite', + [theme.transitions.handleMotion('no-preference')]: { + animationName: bounce, + animationDuration: '0.9s', + animationIterationCount: 'infinite', + }, }), logo: css({ display: 'inline-block', - animationName: squash, - animationDuration: '0.9s', - animationIterationCount: 'infinite', + [theme.transitions.handleMotion('no-preference')]: { + animationName: squash, + animationDuration: '0.9s', + animationIterationCount: 'infinite', + }, width: '60px', height: '60px', }), diff --git a/public/app/core/components/Login/LoginLayout.tsx b/public/app/core/components/Login/LoginLayout.tsx index cb391662213..c7ef7649be5 100644 --- a/public/app/core/components/Login/LoginLayout.tsx +++ b/public/app/core/components/Login/LoginLayout.tsx @@ -2,7 +2,7 @@ import { cx, css, keyframes } from '@emotion/css'; import React, { useEffect, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { handleReducedMotion, useStyles2 } from '@grafana/ui'; +import { useStyles2 } from '@grafana/ui'; import { Branding } from '../Branding/Branding'; import { BrandingSettings } from '../Branding/types'; @@ -148,7 +148,9 @@ export const getLoginStyles = (theme: GrafanaTheme2) => { borderRadius: theme.shape.radius.default, padding: theme.spacing(2, 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')]: { minHeight: theme.spacing(40), @@ -171,12 +173,14 @@ export const getLoginStyles = (theme: GrafanaTheme2) => { maxWidth: 415, width: '100%', transform: 'translate(0px, 0px)', - transition: '0.25s ease', + [theme.transitions.handleMotion('no-preference')]: { + transition: '0.25s ease', + }, }), enterAnimation: css({ - ...handleReducedMotion({ + [theme.transitions.handleMotion('no-preference')]: { animation: `${flyInAnimation} ease-out 0.2s`, - }), + }, }), }; }; diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx index 7914bd495b0..8b5abe2c7c4 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/contactPoint/ContactPointSelector.tsx @@ -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 { 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) => ({ contactPointsSelector: css({ display: 'flex', @@ -172,14 +181,11 @@ const getStyles = (theme: GrafanaTheme2) => ({ }), loading: css({ pointerEvents: 'none', - animation: 'rotation 2s infinite linear', - '@keyframes rotation': { - from: { - transform: 'rotate(720deg)', - }, - to: { - transform: 'rotate(0deg)', - }, + [theme.transitions.handleMotion('no-preference')]: { + animation: `${rotation} 2s infinite linear`, + }, + [theme.transitions.handleMotion('reduce')]: { + animation: `${rotation} 6s infinite linear`, }, }), warn: css({ diff --git a/public/app/features/canvas/elements/droneFront.tsx b/public/app/features/canvas/elements/droneFront.tsx index 29921fced33..031df8af28b 100644 --- a/public/app/features/canvas/elements/droneFront.tsx +++ b/public/app/features/canvas/elements/droneFront.tsx @@ -122,6 +122,8 @@ export const droneFrontItem: CanvasElementItem = { const getStyles = (theme: GrafanaTheme2) => ({ 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', }), }); diff --git a/public/app/features/canvas/elements/droneSide.tsx b/public/app/features/canvas/elements/droneSide.tsx index aa9ad4b9561..a82b11d3ff1 100644 --- a/public/app/features/canvas/elements/droneSide.tsx +++ b/public/app/features/canvas/elements/droneSide.tsx @@ -121,6 +121,8 @@ export const droneSideItem: CanvasElementItem = { const getStyles = (theme: GrafanaTheme2) => ({ 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', }), }); diff --git a/public/app/features/canvas/elements/droneTop.tsx b/public/app/features/canvas/elements/droneTop.tsx index f4c2bff38f7..31584b00583 100644 --- a/public/app/features/canvas/elements/droneTop.tsx +++ b/public/app/features/canvas/elements/droneTop.tsx @@ -180,9 +180,13 @@ const getStyles = (theme: GrafanaTheme2) => ({ }, }), 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', }), 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', }), }); diff --git a/public/app/features/canvas/elements/server/server.tsx b/public/app/features/canvas/elements/server/server.tsx index 633959b929c..cdb69ea7fbd 100644 --- a/public/app/features/canvas/elements/server/server.tsx +++ b/public/app/features/canvas/elements/server/server.tsx @@ -173,7 +173,9 @@ export const getServerStyles = (data: ServerData | undefined) => (theme: Grafana fill: data?.statusColor ?? 'transparent', }), 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, stroke: 'none', }), diff --git a/public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx b/public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx index cda9bf63d9e..345ab73e352 100644 --- a/public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddLibraryPanelWidget/AddLibraryPanelWidget.tsx @@ -90,7 +90,9 @@ const getStyles = (theme: GrafanaTheme2) => { fontSize: theme.typography.fontSize, fontWeight: theme.typography.fontWeightMedium, 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', '&:hover': { @@ -102,7 +104,9 @@ const getStyles = (theme: GrafanaTheme2) => { outline: '2px dotted transparent', outlineOffset: '2px', 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`, + }, }), }; }; diff --git a/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx b/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx index a93406e4a39..8bd51c865b8 100644 --- a/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx +++ b/public/app/features/dashboard/components/DashboardLoading/DashboardLoading.tsx @@ -50,7 +50,9 @@ export const getStyles = (theme: GrafanaTheme2) => { opacity: '0%', alignItems: '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({ fontSize: theme.typography.h4.fontSize, diff --git a/public/app/features/explore/ExploreDrawer.tsx b/public/app/features/explore/ExploreDrawer.tsx index af4c41922e4..2b5c82f0b9b 100644 --- a/public/app/features/explore/ExploreDrawer.tsx +++ b/public/app/features/explore/ExploreDrawer.tsx @@ -68,6 +68,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ }), drawerActive: css({ opacity: 1, - animation: `0.5s ease-out ${drawerSlide(theme)}`, + [theme.transitions.handleMotion('no-preference')]: { + animation: `0.5s ease-out ${drawerSlide(theme)}`, + }, }), }); diff --git a/public/app/features/explore/Logs/LogsTableWrap.tsx b/public/app/features/explore/Logs/LogsTableWrap.tsx index 9580773368a..d80f620475a 100644 --- a/public/app/features/explore/Logs/LogsTableWrap.tsx +++ b/public/app/features/explore/Logs/LogsTableWrap.tsx @@ -567,7 +567,9 @@ function getStyles(theme: GrafanaTheme2, height: number, width: number) { }), rzHandle: css({ 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', height: '50% !important', width: `${theme.spacing(1)} !important`, diff --git a/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx b/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx index ba6bad69f35..8b651759f6e 100644 --- a/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx +++ b/public/app/features/panel/components/VizTypePicker/PanelTypeCard.tsx @@ -138,9 +138,11 @@ const getStyles = (theme: GrafanaTheme2) => { padding: theme.spacing(1), width: '100%', overflow: 'hidden', - transition: theme.transitions.create(['background'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background'], { + duration: theme.transitions.duration.short, + }), + }, '&:hover': { background: theme.colors.emphasize(theme.colors.background.secondary, 0.03), diff --git a/public/app/features/plugins/admin/components/PluginListItem.tsx b/public/app/features/plugins/admin/components/PluginListItem.tsx index a512b23be16..584656809c0 100644 --- a/public/app/features/plugins/admin/components/PluginListItem.tsx +++ b/public/app/features/plugins/admin/components/PluginListItem.tsx @@ -100,9 +100,11 @@ export const getStyles = (theme: GrafanaTheme2) => { background: theme.colors.background.secondary, borderRadius: theme.shape.radius.default, padding: theme.spacing(3), - transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { - duration: theme.transitions.duration.short, - }), + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), + }, '&:hover': { background: theme.colors.emphasize(theme.colors.background.secondary, 0.03), diff --git a/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx b/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx index 54c42a22830..04c1c95b574 100644 --- a/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx +++ b/public/app/plugins/panel/timeseries/plugins/ExemplarMarker.tsx @@ -307,7 +307,9 @@ const getExemplarMarkerStyles = (theme: GrafanaTheme2) => { marble: css({ display: 'block', opacity: 0.5, - transition: 'transform 0.15s ease-out', + [theme.transitions.handleMotion('no-preference')]: { + transition: 'transform 0.15s ease-out', + }, }), activeMarble: css({ transform: 'scale(1.3)',