Skip to content

Commit 539a210

Browse files
committed
fontend: Loader: Fix to not freeze when main thread freezes
This avoids SVG that mui CircleProgress uses, so it doesn't freeze. Also avoids flashing if the loading is finished before 0.25 seconds. Fixes a11y issue to not use progressbar, because we always do not know the progress, but instead are announcing a status.
1 parent 1e5ee48 commit 539a210

File tree

2 files changed

+131
-23
lines changed

2 files changed

+131
-23
lines changed

frontend/src/components/common/Loader.test.tsx

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -28,11 +28,11 @@ describe('Loader Component', () => {
2828
);
2929

3030
// Check if the container Box is present
31-
const container = screen.getByRole('progressbar').parentElement;
31+
const container = screen.getByRole('status').parentElement;
3232
expect(container).toHaveClass('MuiBox-root');
3333

3434
// Check if CircularProgress is rendered
35-
const progress = screen.getByRole('progressbar');
35+
const progress = screen.getByRole('status');
3636
expect(progress).toHaveClass('MuiCircularProgress-root');
3737
expect(progress).toHaveAttribute('title', 'Loading...');
3838
});
@@ -45,7 +45,7 @@ describe('Loader Component', () => {
4545
);
4646

4747
// Check if CircularProgress is rendered directly without container
48-
const progress = screen.getByRole('progressbar');
48+
const progress = screen.getByRole('status');
4949
expect(progress).toHaveClass('MuiCircularProgress-root');
5050
expect(progress.parentElement).not.toHaveClass('MuiBox-root');
5151
});
@@ -58,7 +58,7 @@ describe('Loader Component', () => {
5858
</TestContext>
5959
);
6060

61-
const progress = screen.getByRole('progressbar');
61+
const progress = screen.getByRole('status');
6262
expect(progress).toHaveStyle({ width: `${customSize}px`, height: `${customSize}px` });
6363
});
6464

@@ -69,7 +69,7 @@ describe('Loader Component', () => {
6969
</TestContext>
7070
);
7171

72-
const progress = screen.getByRole('progressbar');
72+
const progress = screen.getByRole('status');
7373
expect(progress).toHaveClass('MuiCircularProgress-colorSecondary');
7474
});
7575

@@ -80,20 +80,7 @@ describe('Loader Component', () => {
8080
</TestContext>
8181
);
8282

83-
const progress = screen.getByRole('progressbar');
83+
const progress = screen.getByRole('status');
8484
expect(progress).toHaveAttribute('title', '');
8585
});
86-
87-
it('passes additional props to CircularProgress', () => {
88-
render(
89-
<TestContext>
90-
<Loader title="Loading..." thickness={4} disableShrink />
91-
</TestContext>
92-
);
93-
94-
const progress = screen.getByRole('progressbar');
95-
expect(progress).toHaveClass('MuiCircularProgress-root');
96-
expect(progress).toHaveAttribute('role', 'progressbar');
97-
expect(progress).toHaveAttribute('title', 'Loading...');
98-
});
9986
});

frontend/src/components/common/Loader.tsx

Lines changed: 125 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,138 @@
1515
*/
1616

1717
import Box from '@mui/material/Box';
18-
import CircularProgress, { CircularProgressProps } from '@mui/material/CircularProgress';
18+
import { useTheme } from '@mui/material/styles';
1919
import React from 'react';
2020

21-
export interface LoaderProps extends CircularProgressProps {
21+
/**
22+
* - Uses CSS animations so does not freeze when main thread is busy
23+
* - Does not display right away, to avoid flash of spinner
24+
*/
25+
26+
export interface LoaderProps {
27+
/** If true, the loader will not be wrapped in a container Box. */
2228
noContainer?: boolean;
29+
/** Title for the loader, used for accessibility and as a tooltip. */
2330
title: string;
31+
/** Size of the loader. Defaults to 40. */
32+
size?: number;
33+
/** Color of the loader. Defaults to primary color. */
34+
color?: 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' | string;
35+
}
36+
37+
/**
38+
* Injects custom loader styles into the document, once for each theme.
39+
*/
40+
function injectLoaderStyle(theme: any) {
41+
const styleId = `hl-custom-loader-style-${theme.palette.primary.main.replace(
42+
/[^a-zA-Z0-9]/g,
43+
''
44+
)}`;
45+
if (!document.getElementById(styleId)) {
46+
const style = document.createElement('style');
47+
style.id = styleId;
48+
style.textContent = `
49+
@keyframes hl-custom-loader-spin {
50+
100% { transform: rotate(360deg); }
51+
}
52+
@keyframes hl-custom-loader-delay {
53+
0% { opacity: 0; }
54+
99% { opacity: 0; }
55+
100% { opacity: 1; }
56+
}
57+
.hl-custom-loader {
58+
display: inline-block;
59+
position: relative;
60+
opacity: 0;
61+
animation: hl-custom-loader-delay 0.25s linear forwards;
62+
}
63+
.hl-custom-loader-circle {
64+
box-sizing: border-box;
65+
position: absolute;
66+
top: 0; left: 0;
67+
border-radius: 50%;
68+
border-right-color: transparent;
69+
border-bottom-color: transparent;
70+
animation: hl-custom-loader-spin 0.8s linear infinite;
71+
}
72+
`;
73+
document.head.appendChild(style);
74+
}
75+
}
76+
77+
/**
78+
* @returns a color value based on the theme and the provided color prop.
79+
* If no color is provided, it defaults to the primary color of the theme.
80+
*/
81+
function getColor(theme: any, color?: LoaderProps['color']) {
82+
if (!color || color === 'primary') return theme.palette.primary.main;
83+
if (color === 'secondary') return theme.palette.secondary.main;
84+
if (color === 'error') return theme.palette.error.main;
85+
if (color === 'info') return theme.palette.info.main;
86+
if (color === 'success') return theme.palette.success.main;
87+
if (color === 'warning') return theme.palette.warning.main;
88+
return color;
89+
}
90+
91+
/**
92+
* @returns a MUI class name based on the provided color prop.
93+
* This is used to apply the correct color to the CircularProgress component.
94+
*/
95+
function getMuiColorClass(color?: LoaderProps['color']) {
96+
if (!color || color === 'primary') return 'MuiCircularProgress-colorPrimary';
97+
if (color === 'secondary') return 'MuiCircularProgress-colorSecondary';
98+
if (color === 'error') return 'MuiCircularProgress-colorError';
99+
if (color === 'info') return 'MuiCircularProgress-colorInfo';
100+
if (color === 'success') return 'MuiCircularProgress-colorSuccess';
101+
if (color === 'warning') return 'MuiCircularProgress-colorWarning';
102+
return '';
103+
}
104+
105+
/**
106+
* CustomLoader component that renders a circular loader with custom styles.
107+
*/
108+
function CustomLoader({
109+
title,
110+
size = 40,
111+
color,
112+
}: {
113+
title: LoaderProps['title'];
114+
size?: LoaderProps['size'];
115+
color?: LoaderProps['color'];
116+
}) {
117+
const theme = useTheme();
118+
React.useEffect(() => {
119+
injectLoaderStyle(theme);
120+
}, [theme]);
121+
const loaderColor = getColor(theme, color);
122+
const muiColorClass = getMuiColorClass(color);
123+
124+
return (
125+
<span
126+
className={`MuiCircularProgress-root MuiCircularProgress-indeterminate hl-custom-loader ${muiColorClass}`}
127+
role="status"
128+
aria-label={title}
129+
aria-live="polite"
130+
title={title}
131+
style={{ width: size, height: size }}
132+
>
133+
<span
134+
className="hl-custom-loader-circle"
135+
style={{
136+
width: '100%',
137+
height: '100%',
138+
borderWidth: Math.max(2, Math.round(size / 10)),
139+
borderStyle: 'solid',
140+
borderColor: `${loaderColor} transparent transparent transparent`,
141+
}}
142+
/>
143+
</span>
144+
);
24145
}
25146

26147
export default function Loader(props: LoaderProps) {
27-
const { noContainer = false, title, ...other } = props;
28-
const progress = <CircularProgress title={title} {...other} />;
148+
const { noContainer = false, title, size, color } = props;
149+
const progress = <CustomLoader title={title} size={size} color={color} />;
29150

30151
if (noContainer) return progress;
31152

0 commit comments

Comments
 (0)