mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 15:36:48 +00:00
feat: update app loading screen and add system theme option (#8567)
* feat: update app loading screen and add system theme option * feat: update test case
This commit is contained in:
parent
55eadf914b
commit
a576982497
@ -3,6 +3,7 @@ import { ConfigProvider } from 'antd';
|
||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import AppLoading from 'components/AppLoading/AppLoading';
|
||||
import NotFound from 'components/NotFound';
|
||||
import Spinner from 'components/Spinner';
|
||||
import UserpilotRouteTracker from 'components/UserpilotRouteTracker/UserpilotRouteTracker';
|
||||
@ -342,7 +343,7 @@ function App(): JSX.Element {
|
||||
if (isLoggedInState) {
|
||||
// if the setup calls are loading then return a spinner
|
||||
if (isFetchingActiveLicense || isFetchingUser || isFetchingFeatureFlags) {
|
||||
return <Spinner tip="Loading..." />;
|
||||
return <AppLoading />;
|
||||
}
|
||||
|
||||
// if the required calls fails then return a something went wrong error
|
||||
|
||||
152
frontend/src/components/AppLoading/AppLoading.styles.scss
Normal file
152
frontend/src/components/AppLoading/AppLoading.styles.scss
Normal file
@ -0,0 +1,152 @@
|
||||
.app-loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
background-color: var(--bg-ink-400, #121317); // Dark theme background
|
||||
|
||||
.app-loading-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
|
||||
margin-bottom: 12px;
|
||||
|
||||
.brand-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--bg-vanilla-100, #ffffff); // White text for dark theme
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
margin-bottom: 24px;
|
||||
|
||||
.ant-typography {
|
||||
color: var(--bg-vanilla-400, #c0c1c3); // Light gray text for dark theme
|
||||
}
|
||||
}
|
||||
|
||||
/* HTML: <div class="loader"></div> */
|
||||
.loader {
|
||||
width: 150px;
|
||||
height: 12px;
|
||||
border-radius: 2px;
|
||||
color: var(--bg-robin-500, #4e74f8); // Primary blue color
|
||||
border: 2px solid;
|
||||
position: relative;
|
||||
}
|
||||
.loader::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
margin: 2px;
|
||||
inset: 0 100% 0 0;
|
||||
border-radius: inherit;
|
||||
background: currentColor;
|
||||
animation: l6 2s infinite;
|
||||
}
|
||||
@keyframes l6 {
|
||||
100% {
|
||||
inset: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Light theme styles - more specific selector
|
||||
.app-loading-container.lightMode {
|
||||
background-color: var(
|
||||
--bg-vanilla-100,
|
||||
#ffffff
|
||||
) !important; // White background for light theme
|
||||
|
||||
.app-loading-content {
|
||||
.brand {
|
||||
.brand-title {
|
||||
color: var(--bg-ink-400, #121317) !important; // Dark text for light theme
|
||||
}
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
.ant-typography {
|
||||
color: var(
|
||||
--bg-ink-300,
|
||||
#6b7280
|
||||
) !important; // Dark gray text for light theme
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
color: var(
|
||||
--bg-robin-500,
|
||||
#4e74f8
|
||||
) !important; // Keep primary blue color for consistency
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.perilin-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
background: radial-gradient(circle, #fff 10%, transparent 0);
|
||||
background-size: 12px 12px;
|
||||
opacity: 1;
|
||||
|
||||
mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
-webkit-mask-image: radial-gradient(
|
||||
circle at 50% 0,
|
||||
rgba(11, 12, 14, 0.1) 0,
|
||||
rgba(11, 12, 14, 0) 100%
|
||||
);
|
||||
}
|
||||
|
||||
// Dark theme styles - ensure dark theme is properly applied
|
||||
.app-loading-container.dark {
|
||||
background-color: var(--bg-ink-400, #121317) !important; // Dark background
|
||||
|
||||
.app-loading-content {
|
||||
.brand {
|
||||
.brand-title {
|
||||
color: var(
|
||||
--bg-vanilla-100,
|
||||
#ffffff
|
||||
) !important; // White text for dark theme
|
||||
}
|
||||
}
|
||||
|
||||
.brand-tagline {
|
||||
.ant-typography {
|
||||
color: var(
|
||||
--bg-vanilla-400,
|
||||
#c0c1c3
|
||||
) !important; // Light gray text for dark theme
|
||||
}
|
||||
}
|
||||
|
||||
.loader {
|
||||
color: var(--bg-robin-500, #4e74f8) !important; // Primary blue color
|
||||
}
|
||||
}
|
||||
}
|
||||
50
frontend/src/components/AppLoading/AppLoading.tsx
Normal file
50
frontend/src/components/AppLoading/AppLoading.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import './AppLoading.styles.scss';
|
||||
|
||||
import { Typography } from 'antd';
|
||||
import get from 'api/browser/localstorage/get';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||
|
||||
function AppLoading(): JSX.Element {
|
||||
// Get theme from localStorage directly to avoid context dependency
|
||||
const getThemeFromStorage = (): boolean => {
|
||||
try {
|
||||
const theme = get(LOCALSTORAGE.THEME);
|
||||
return theme !== THEME_MODE.LIGHT; // Return true for dark, false for light
|
||||
} catch (error) {
|
||||
// If localStorage is not available, default to dark theme
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const isDarkMode = getThemeFromStorage();
|
||||
|
||||
return (
|
||||
<div className={`app-loading-container ${isDarkMode ? 'dark' : 'lightMode'}`}>
|
||||
<div className="perilin-bg" />
|
||||
<div className="app-loading-content">
|
||||
<div className="brand">
|
||||
<img
|
||||
src="/Logos/signoz-brand-logo.svg"
|
||||
alt="SigNoz"
|
||||
className="brand-logo"
|
||||
/>
|
||||
|
||||
<Typography.Title level={2} className="brand-title">
|
||||
SigNoz
|
||||
</Typography.Title>
|
||||
</div>
|
||||
|
||||
<div className="brand-tagline">
|
||||
<Typography.Text>
|
||||
OpenTelemetry-Native Logs, Metrics and Traces in a single pane
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="loader" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppLoading;
|
||||
@ -0,0 +1,76 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import AppLoading from '../AppLoading';
|
||||
|
||||
// Mock the localStorage API
|
||||
const mockGet = jest.fn();
|
||||
jest.mock('api/browser/localstorage/get', () => ({
|
||||
__esModule: true,
|
||||
default: mockGet,
|
||||
}));
|
||||
|
||||
describe('AppLoading', () => {
|
||||
const SIGNOZ_TEXT = 'SigNoz';
|
||||
const TAGLINE_TEXT =
|
||||
'OpenTelemetry-Native Logs, Metrics and Traces in a single pane';
|
||||
const CONTAINER_SELECTOR = '.app-loading-container';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render loading screen with dark theme by default', () => {
|
||||
// Mock localStorage to return dark theme (or undefined for default)
|
||||
mockGet.mockReturnValue(undefined);
|
||||
|
||||
render(<AppLoading />);
|
||||
|
||||
// Check if main elements are rendered
|
||||
expect(screen.getByAltText(SIGNOZ_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument();
|
||||
expect(screen.getByText(TAGLINE_TEXT)).toBeInTheDocument();
|
||||
|
||||
// Check if dark theme class is applied
|
||||
const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR);
|
||||
expect(container).toHaveClass('dark');
|
||||
expect(container).not.toHaveClass('lightMode');
|
||||
});
|
||||
|
||||
it('should have proper structure and content', () => {
|
||||
// Mock localStorage to return dark theme
|
||||
mockGet.mockReturnValue(undefined);
|
||||
|
||||
render(<AppLoading />);
|
||||
|
||||
// Check for brand logo
|
||||
const logo = screen.getByAltText(SIGNOZ_TEXT);
|
||||
expect(logo).toBeInTheDocument();
|
||||
expect(logo).toHaveAttribute('src', '/Logos/signoz-brand-logo.svg');
|
||||
|
||||
// Check for brand title
|
||||
const title = screen.getByText(SIGNOZ_TEXT);
|
||||
expect(title).toBeInTheDocument();
|
||||
|
||||
// Check for tagline
|
||||
const tagline = screen.getByText(TAGLINE_TEXT);
|
||||
expect(tagline).toBeInTheDocument();
|
||||
|
||||
// Check for loader
|
||||
const loader = document.querySelector('.loader');
|
||||
expect(loader).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle localStorage errors gracefully', () => {
|
||||
// Mock localStorage to throw an error
|
||||
mockGet.mockImplementation(() => {
|
||||
throw new Error('localStorage not available');
|
||||
});
|
||||
|
||||
render(<AppLoading />);
|
||||
|
||||
// Should still render with dark theme as fallback
|
||||
expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument();
|
||||
const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR);
|
||||
expect(container).toHaveClass('dark');
|
||||
});
|
||||
});
|
||||
@ -4,6 +4,7 @@ export enum LOCALSTORAGE {
|
||||
AUTH_TOKEN = 'AUTH_TOKEN',
|
||||
REFRESH_AUTH_TOKEN = 'REFRESH_AUTH_TOKEN',
|
||||
THEME = 'THEME',
|
||||
THEME_AUTO_SWITCH = 'THEME_AUTO_SWITCH',
|
||||
LOGS_VIEW_MODE = 'LOGS_VIEW_MODE',
|
||||
LOGS_LINES_PER_ROW = 'LOGS_LINES_PER_ROW',
|
||||
LOGS_LIST_OPTIONS = 'LOGS_LIST_OPTIONS',
|
||||
|
||||
@ -120,6 +120,28 @@
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.auto-theme-info {
|
||||
margin-top: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-slate-400, #1d212d);
|
||||
border: 1px solid var(--bg-slate-500, #161922);
|
||||
|
||||
.auto-theme-status {
|
||||
color: var(--bg-vanilla-400, #c0c1c3);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
line-height: 16px;
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
strong {
|
||||
color: var(--bg-robin-400, #4e74f8);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -168,6 +190,19 @@
|
||||
.user-preference-section-content-item-description {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
.auto-theme-info {
|
||||
background: var(--bg-vanilla-200);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
|
||||
.auto-theme-status {
|
||||
color: var(--bg-ink-300);
|
||||
|
||||
strong {
|
||||
color: var(--bg-robin-500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,11 @@ const logEventFunction = jest.fn();
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
__esModule: true,
|
||||
useIsDarkMode: jest.fn(() => true),
|
||||
useSystemTheme: jest.fn(() => 'dark'),
|
||||
default: jest.fn(() => ({
|
||||
toggleTheme: toggleThemeFunction,
|
||||
autoSwitch: false,
|
||||
setAutoSwitch: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
@ -45,7 +48,7 @@ describe('MySettings Flows', () => {
|
||||
});
|
||||
|
||||
describe('Dark/Light Theme Switch', () => {
|
||||
it('Should display Dark and Light theme options properly', async () => {
|
||||
it('Should display Dark, Light, and System theme options properly', async () => {
|
||||
// Check Dark theme option
|
||||
expect(screen.getByText('Dark')).toBeInTheDocument();
|
||||
const darkThemeIcon = screen.getByTestId('dark-theme-icon');
|
||||
@ -58,6 +61,12 @@ describe('MySettings Flows', () => {
|
||||
expect(lightThemeIcon).toBeInTheDocument();
|
||||
expect(lightThemeIcon.tagName).toBe('svg');
|
||||
expect(screen.getByText('Beta')).toBeInTheDocument();
|
||||
|
||||
// Check System theme option
|
||||
expect(screen.getByText('System')).toBeInTheDocument();
|
||||
const autoThemeIcon = screen.getByTestId('auto-theme-icon');
|
||||
expect(autoThemeIcon).toBeInTheDocument();
|
||||
expect(autoThemeIcon.tagName).toBe('svg');
|
||||
});
|
||||
|
||||
it('Should have Dark theme selected by default', async () => {
|
||||
|
||||
@ -5,9 +5,9 @@ import logEvent from 'api/common/logEvent';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import { AxiosError } from 'axios';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import useThemeMode, { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import useThemeMode, { useIsDarkMode, useSystemTheme } from 'hooks/useDarkMode';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { MonitorCog, Moon, Sun } from 'lucide-react';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation } from 'react-query';
|
||||
@ -19,8 +19,9 @@ import UserInfo from './UserInfo';
|
||||
|
||||
function MySettings(): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { toggleTheme } = useThemeMode();
|
||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||
const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode();
|
||||
const systemTheme = useSystemTheme();
|
||||
const { notifications } = useNotifications();
|
||||
|
||||
const [sideNavPinned, setSideNavPinned] = useState(false);
|
||||
@ -68,16 +69,37 @@ function MySettings(): JSX.Element {
|
||||
),
|
||||
value: 'light',
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<div className="theme-option">
|
||||
<MonitorCog size={12} data-testid="auto-theme-icon" /> System{' '}
|
||||
</div>
|
||||
),
|
||||
value: 'auto',
|
||||
},
|
||||
];
|
||||
|
||||
const [theme, setTheme] = useState(isDarkMode ? 'dark' : 'light');
|
||||
const [theme, setTheme] = useState(() => {
|
||||
if (autoSwitch) return 'auto';
|
||||
return isDarkMode ? 'dark' : 'light';
|
||||
});
|
||||
|
||||
const handleThemeChange = ({ target: { value } }: RadioChangeEvent): void => {
|
||||
logEvent('Account Settings: Theme Changed', {
|
||||
theme: value,
|
||||
});
|
||||
setTheme(value);
|
||||
|
||||
if (value === 'auto') {
|
||||
setAutoSwitch(true);
|
||||
} else {
|
||||
setAutoSwitch(false);
|
||||
// Only toggle if the current theme is different from the target
|
||||
const targetIsDark = value === 'dark';
|
||||
if (targetIsDark !== isDarkMode) {
|
||||
toggleTheme();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSideNavPinnedChange = (checked: boolean): void => {
|
||||
@ -150,13 +172,23 @@ function MySettings(): JSX.Element {
|
||||
optionType="button"
|
||||
buttonStyle="solid"
|
||||
data-testid="theme-selector"
|
||||
size="small"
|
||||
size="middle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="user-preference-section-content-item-description">
|
||||
Select if SigNoz's appearance should be light or dark
|
||||
Select if SigNoz's appearance should be light, dark, or
|
||||
automatically follow your system preference
|
||||
</div>
|
||||
|
||||
{autoSwitch && (
|
||||
<div className="auto-theme-info">
|
||||
<div className="auto-theme-status">
|
||||
Currently following system theme:{' '}
|
||||
<strong>{systemTheme === 'dark' ? 'Dark' : 'Light'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<TimezoneAdaptation />
|
||||
|
||||
169
frontend/src/hooks/useDarkMode/__tests__/useDarkMode.test.tsx
Normal file
169
frontend/src/hooks/useDarkMode/__tests__/useDarkMode.test.tsx
Normal file
@ -0,0 +1,169 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
ThemeProvider,
|
||||
useIsDarkMode,
|
||||
useSystemTheme,
|
||||
useThemeMode,
|
||||
} from '../index';
|
||||
|
||||
// Mock localStorage
|
||||
const localStorageMock = {
|
||||
getItem: jest.fn(),
|
||||
setItem: jest.fn(),
|
||||
clear: jest.fn(),
|
||||
};
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
value: localStorageMock,
|
||||
});
|
||||
|
||||
// Helper function to create matchMedia mock
|
||||
const createMatchMediaMock = (prefersDark: boolean): jest.Mock =>
|
||||
jest.fn().mockImplementation((query: string) => ({
|
||||
matches:
|
||||
query === '(prefers-color-scheme: dark)' ? prefersDark : !prefersDark,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock matchMedia
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: createMatchMediaMock(true), // Default to dark theme
|
||||
});
|
||||
|
||||
describe('useDarkMode', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
localStorageMock.getItem.mockReturnValue(null);
|
||||
});
|
||||
|
||||
describe('useSystemTheme', () => {
|
||||
it('should return dark theme by default', () => {
|
||||
const { result } = renderHook(() => useSystemTheme());
|
||||
expect(result.current).toBe('dark');
|
||||
});
|
||||
|
||||
it('should return light theme when system prefers light', () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: createMatchMediaMock(false), // Light theme
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useSystemTheme());
|
||||
expect(result.current).toBe('light');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
it('should provide theme context with default values', () => {
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||
|
||||
const { result } = renderHook(() => useThemeMode(), { wrapper });
|
||||
|
||||
expect(result.current.theme).toBe('dark');
|
||||
expect(typeof result.current.toggleTheme).toBe('function');
|
||||
expect(result.current.autoSwitch).toBe(false);
|
||||
expect(typeof result.current.setAutoSwitch).toBe('function');
|
||||
});
|
||||
|
||||
it('should load theme from localStorage', () => {
|
||||
localStorageMock.getItem.mockImplementation((key: string) => {
|
||||
if (key === 'THEME') return 'light';
|
||||
if (key === 'THEME_AUTO_SWITCH') return 'true';
|
||||
return null;
|
||||
});
|
||||
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||
|
||||
const { result } = renderHook(() => useThemeMode(), { wrapper });
|
||||
|
||||
expect(result.current.theme).toBe('light');
|
||||
expect(result.current.autoSwitch).toBe(true);
|
||||
});
|
||||
|
||||
it('should toggle theme correctly', () => {
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||
|
||||
const { result } = renderHook(() => useThemeMode(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.toggleTheme();
|
||||
});
|
||||
|
||||
expect(result.current.theme).toBe('light');
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('THEME', 'light');
|
||||
});
|
||||
|
||||
it('should handle auto-switch functionality', () => {
|
||||
// Mock system theme as light
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: createMatchMediaMock(false), // Light theme
|
||||
});
|
||||
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||
|
||||
const { result } = renderHook(() => useThemeMode(), { wrapper });
|
||||
|
||||
act(() => {
|
||||
result.current.setAutoSwitch(true);
|
||||
});
|
||||
|
||||
expect(result.current.autoSwitch).toBe(true);
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||
'THEME_AUTO_SWITCH',
|
||||
'true',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useIsDarkMode', () => {
|
||||
it('should return true for dark theme', () => {
|
||||
localStorageMock.getItem.mockReturnValue('dark');
|
||||
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||
|
||||
const { result } = renderHook(() => useIsDarkMode(), { wrapper });
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for light theme', () => {
|
||||
localStorageMock.getItem.mockReturnValue('light');
|
||||
|
||||
const wrapper = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||
|
||||
const { result } = renderHook(() => useIsDarkMode(), { wrapper });
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -5,9 +5,12 @@ import set from 'api/browser/localstorage/set';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import {
|
||||
createContext,
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
@ -16,13 +19,54 @@ import { THEME_MODE } from './constant';
|
||||
|
||||
export const ThemeContext = createContext({
|
||||
theme: THEME_MODE.DARK,
|
||||
toggleTheme: () => {},
|
||||
toggleTheme: (): void => {},
|
||||
autoSwitch: false,
|
||||
setAutoSwitch: ((): void => {}) as Dispatch<SetStateAction<boolean>>,
|
||||
});
|
||||
|
||||
// Hook to detect system theme preference
|
||||
export const useSystemTheme = (): 'light' | 'dark' => {
|
||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>('dark');
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
setSystemTheme(mediaQuery.matches ? 'dark' : 'light');
|
||||
|
||||
const handler = (e: MediaQueryListEvent): void => {
|
||||
setSystemTheme(e.matches ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
return (): void => mediaQuery.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
return systemTheme;
|
||||
};
|
||||
|
||||
export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
|
||||
const [theme, setTheme] = useState(get(LOCALSTORAGE.THEME) || THEME_MODE.DARK);
|
||||
const [autoSwitch, setAutoSwitch] = useState(
|
||||
get(LOCALSTORAGE.THEME_AUTO_SWITCH) === 'true',
|
||||
);
|
||||
const systemTheme = useSystemTheme();
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
// Handle auto-switch functionality
|
||||
useEffect(() => {
|
||||
if (autoSwitch) {
|
||||
const newTheme = systemTheme === 'dark' ? THEME_MODE.DARK : THEME_MODE.LIGHT;
|
||||
if (newTheme !== theme) {
|
||||
setTheme(newTheme);
|
||||
set(LOCALSTORAGE.THEME, newTheme);
|
||||
}
|
||||
}
|
||||
}, [systemTheme, autoSwitch, theme]);
|
||||
|
||||
// Save auto-switch preference
|
||||
useEffect(() => {
|
||||
set(LOCALSTORAGE.THEME_AUTO_SWITCH, autoSwitch.toString());
|
||||
}, [autoSwitch]);
|
||||
|
||||
const toggleTheme = useCallback((): void => {
|
||||
if (theme === THEME_MODE.LIGHT) {
|
||||
setTheme(THEME_MODE.DARK);
|
||||
set(LOCALSTORAGE.THEME, THEME_MODE.DARK);
|
||||
@ -37,8 +81,10 @@ export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
|
||||
() => ({
|
||||
theme,
|
||||
toggleTheme,
|
||||
autoSwitch,
|
||||
setAutoSwitch,
|
||||
}),
|
||||
[theme, toggleTheme],
|
||||
[theme, toggleTheme, autoSwitch, setAutoSwitch],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
@ -51,12 +97,16 @@ interface ThemeProviderProps {
|
||||
interface ThemeMode {
|
||||
theme: string;
|
||||
toggleTheme: () => void;
|
||||
autoSwitch: boolean;
|
||||
setAutoSwitch: Dispatch<SetStateAction<boolean>>;
|
||||
}
|
||||
|
||||
export const useThemeMode = (): ThemeMode => {
|
||||
const { theme, toggleTheme } = useContext(ThemeContext);
|
||||
const { theme, toggleTheme, autoSwitch, setAutoSwitch } = useContext(
|
||||
ThemeContext,
|
||||
);
|
||||
|
||||
return { theme, toggleTheme };
|
||||
return { theme, toggleTheme, autoSwitch, setAutoSwitch };
|
||||
};
|
||||
|
||||
export const useIsDarkMode = (): boolean => {
|
||||
|
||||
@ -16,6 +16,25 @@ body {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// Theme transition animations
|
||||
* {
|
||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease,
|
||||
box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
// For components that shouldn't transition (like loading spinners, animations)
|
||||
.no-transition,
|
||||
.no-transition * {
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
// Respect user's reduced motion preference
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.u-legend {
|
||||
max-height: 30px; // Default height for backward compatibility
|
||||
overflow-y: auto;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user