Spinner: Change spinner icon when prefers-reduced-motion is set (#87641)

* change spinner icon when prefers-reduced-motion is set

* update mock

* remove outdated comment

* fix matchMedia mocks

* update spinner aria label
This commit is contained in:
Ashley Harrison 2024-05-13 11:32:02 +01:00 committed by GitHub
parent 58e554b67f
commit 9bf3adabd8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 61 additions and 34 deletions

View File

@ -6,6 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../themes'; import { useStyles2 } from '../../themes';
import { IconSize, isIconSize } from '../../types'; import { IconSize, isIconSize } from '../../types';
import { t } from '../../utils/i18n';
import { Icon } from '../Icon/Icon'; import { Icon } from '../Icon/Icon';
import { getIconRoot, getIconSubDir } from '../Icon/utils'; import { getIconRoot, getIconSubDir } from '../Icon/utils';
@ -38,6 +39,8 @@ export const Spinner = ({
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const deprecatedStyles = useStyles2(getDeprecatedStyles, size); const deprecatedStyles = useStyles2(getDeprecatedStyles, size);
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const iconName = prefersReducedMotion ? 'hourglass' : 'spinner';
// this entire if statement is handling the deprecated size prop // this entire if statement is handling the deprecated size prop
// TODO remove once we fully remove the deprecated type // TODO remove once we fully remove the deprecated type
@ -80,7 +83,17 @@ export const Spinner = ({
className className
)} )}
> >
<Icon className={cx('fa-spin', iconClassName)} name="spinner" size={size} aria-label="loading spinner" /> <Icon
className={cx(
{
'fa-spin': !prefersReducedMotion,
},
iconClassName
)}
name={iconName}
size={size}
aria-label={t('grafana-ui.spinner.aria-label', 'Loading')}
/>
</div> </div>
); );
}; };

View File

@ -30,11 +30,14 @@ const renderWithProvider = ({ initialState }: { initialState?: Partial<appTypes.
describe('OrganisationSwitcher', () => { describe('OrganisationSwitcher', () => {
beforeEach(() => { beforeEach(() => {
(window.matchMedia as jest.Mock).mockImplementation(() => ({ jest.spyOn(window, 'matchMedia').mockImplementation(
addEventListener: jest.fn(), () =>
removeEventListener: jest.fn(), ({
matches: true, addEventListener: jest.fn(),
})); removeEventListener: jest.fn(),
matches: true,
}) as unknown as MediaQueryList
);
}); });
it('should only render if more than one organisations', () => { it('should only render if more than one organisations', () => {
@ -80,11 +83,14 @@ describe('OrganisationSwitcher', () => {
}); });
it('should render a picker in mobile screen', () => { it('should render a picker in mobile screen', () => {
(window.matchMedia as jest.Mock).mockImplementation(() => ({ jest.spyOn(window, 'matchMedia').mockImplementation(
addEventListener: jest.fn(), () =>
removeEventListener: jest.fn(), ({
matches: false, addEventListener: jest.fn(),
})); removeEventListener: jest.fn(),
matches: false,
}) as unknown as MediaQueryList
);
renderWithProvider({ renderWithProvider({
initialState: { initialState: {
organization: { organization: {

View File

@ -14,11 +14,14 @@ const renderComponent = (options?: { props: TopSearchBarSectionProps }) => {
describe('TopSearchBarSection', () => { describe('TopSearchBarSection', () => {
it('should use a wrapper on non mobile screen', () => { it('should use a wrapper on non mobile screen', () => {
(window.matchMedia as jest.Mock).mockImplementation(() => ({ jest.spyOn(window, 'matchMedia').mockImplementation(
addEventListener: jest.fn(), () =>
removeEventListener: jest.fn(), ({
matches: true, addEventListener: jest.fn(),
})); removeEventListener: jest.fn(),
matches: true,
}) as unknown as MediaQueryList
);
const component = renderComponent(); const component = renderComponent();
@ -27,11 +30,14 @@ describe('TopSearchBarSection', () => {
}); });
it('should not use a wrapper on mobile screen', () => { it('should not use a wrapper on mobile screen', () => {
(window.matchMedia as jest.Mock).mockImplementation(() => ({ jest.spyOn(window, 'matchMedia').mockImplementation(
addEventListener: jest.fn(), () =>
removeEventListener: jest.fn(), ({
matches: false, addEventListener: jest.fn(),
})); removeEventListener: jest.fn(),
matches: false,
}) as unknown as MediaQueryList
);
const component = renderComponent(); const component = renderComponent();

View File

@ -618,6 +618,9 @@
"select": { "select": {
"no-options-label": "No options found", "no-options-label": "No options found",
"placeholder": "Choose" "placeholder": "Choose"
},
"spinner": {
"aria-label": "Loading"
} }
}, },
"graph": { "graph": {

View File

@ -618,6 +618,9 @@
"select": { "select": {
"no-options-label": "Ńő őpŧįőʼnş ƒőūʼnđ", "no-options-label": "Ńő őpŧįőʼnş ƒőūʼnđ",
"placeholder": "Cĥőőşę" "placeholder": "Cĥőőşę"
},
"spinner": {
"aria-label": "Ŀőäđįʼnģ"
} }
}, },
"graph": { "graph": {

View File

@ -34,19 +34,15 @@ global.grafanaBootData = {
navTree: [], navTree: [],
}; };
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom window.matchMedia = (query) => ({
Object.defineProperty(global, 'matchMedia', { matches: false,
writable: true, media: query,
value: jest.fn().mockImplementation((query) => ({ onchange: null,
matches: false, addListener: jest.fn(), // Deprecated
media: query, removeListener: jest.fn(), // Deprecated
onchange: null, addEventListener: jest.fn(),
addListener: jest.fn(), // deprecated removeEventListener: jest.fn(),
removeListener: jest.fn(), // deprecated dispatchEvent: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
}); });
angular.module('grafana', ['ngRoute']); angular.module('grafana', ['ngRoute']);