This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new decb079 feat: add centralized ThemeContext and useTheme hook for dark
mode management (#435)
decb079 is described below
commit decb079026f6cdff921d4c02e7aef32b5628682b
Author: zhaohai <[email protected]>
AuthorDate: Fri Jul 17 17:09:39 2026 +0800
feat: add centralized ThemeContext and useTheme hook for dark mode
management (#435)
---
web/package.json | 6 +-
web/src/layouts/MainLayout.tsx | 24 +---
web/src/main.tsx | 18 ++-
web/src/test/setup.ts | 1 +
web/src/theme/ThemeContext.tsx | 68 +++++++++++
web/src/theme/__tests__/ThemeContext.test.tsx | 156 ++++++++++++++++++++++++++
6 files changed, 249 insertions(+), 24 deletions(-)
diff --git a/web/package.json b/web/package.json
index 025967d..307eab8 100644
--- a/web/package.json
+++ b/web/package.json
@@ -7,12 +7,12 @@
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
+ "test": "vitest run",
+ "test:watch": "vitest",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write \"src/**/*.{ts,tsx,css}\"",
- "prepare": "cd .. && husky",
- "test": "vitest run",
- "test:watch": "vitest"
+ "prepare": "cd .. && husky"
},
"lint-staged": {
"*.{ts,tsx}": [
diff --git a/web/src/layouts/MainLayout.tsx b/web/src/layouts/MainLayout.tsx
index b214bbe..de7dc55 100644
--- a/web/src/layouts/MainLayout.tsx
+++ b/web/src/layouts/MainLayout.tsx
@@ -23,8 +23,6 @@ import {
Avatar,
Dropdown,
Input,
- ConfigProvider,
- theme,
Modal,
} from 'antd';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
@@ -49,6 +47,7 @@ import {
Notebook,
} from '@phosphor-icons/react';
import { useLang } from '../i18n/LangContext';
+import { useTheme } from '../theme/ThemeContext';
const { Sider, Content } = Layout;
@@ -57,7 +56,7 @@ const iconSize = 18;
const MainLayout = () => {
const navigate = useNavigate();
const location = useLocation();
- const [darkMode, setDarkMode] = useState(false);
+ const { darkMode, toggleTheme } = useTheme();
const [searchOpen, setSearchOpen] = useState(false);
const [searchText, setSearchText] = useState('');
const { lang, setLang, t } = useLang();
@@ -152,20 +151,7 @@ const MainLayout = () => {
const logoColor = darkMode ? '#e5e5e5' : '#1b1b1a';
return (
- <ConfigProvider
- theme={{
- algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
- token: darkMode
- ? {
- colorBgBase: '#2a2a2e',
- colorBgContainer: '#323236',
- colorBgElevated: '#3a3a3e',
- colorBorder: '#3a3a3e',
- colorBorderSecondary: '#333337',
- }
- : undefined,
- }}
- >
+ <>
<Layout style={{ minHeight: '100vh' }}>
<Sider
theme={darkMode ? 'dark' : 'light'}
@@ -282,7 +268,7 @@ const MainLayout = () => {
{/* Theme toggle */}
<div
- onClick={() => setDarkMode(!darkMode)}
+ onClick={toggleTheme}
style={{
cursor: 'pointer',
display: 'flex',
@@ -393,7 +379,7 @@ const MainLayout = () => {
))}
</div>
</Modal>
- </ConfigProvider>
+ </>
);
};
diff --git a/web/src/main.tsx b/web/src/main.tsx
index 73c687e..4415ac4 100644
--- a/web/src/main.tsx
+++ b/web/src/main.tsx
@@ -18,25 +18,37 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
-import { ConfigProvider } from 'antd';
+import { ConfigProvider, theme } from 'antd';
import zhCN from 'antd/locale/zh_CN';
import enUS from 'antd/locale/en_US';
import { LangProvider, useLang } from './i18n/LangContext';
+import { ThemeProvider, useTheme } from './theme/ThemeContext';
import App from './App';
import './index.css';
const ThemedApp = () => {
const { lang } = useLang();
+ const { darkMode } = useTheme();
return (
<ConfigProvider
locale={lang === 'zh' ? zhCN : enUS}
theme={{
+ algorithm: darkMode ? theme.darkAlgorithm : theme.defaultAlgorithm,
token: {
colorPrimary: '#1677ff',
borderRadius: 8,
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", sans-serif',
+ ...(darkMode
+ ? {
+ colorBgBase: '#2a2a2e',
+ colorBgContainer: '#323236',
+ colorBgElevated: '#3a3a3e',
+ colorBorder: '#3a3a3e',
+ colorBorderSecondary: '#333337',
+ }
+ : {}),
},
components: {
Card: { borderRadiusLG: 12 },
@@ -56,7 +68,9 @@ const ThemedApp = () => {
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<LangProvider>
- <ThemedApp />
+ <ThemeProvider>
+ <ThemedApp />
+ </ThemeProvider>
</LangProvider>
</React.StrictMode>,
);
diff --git a/web/src/test/setup.ts b/web/src/test/setup.ts
index 3bcbc09..a9f1da5 100644
--- a/web/src/test/setup.ts
+++ b/web/src/test/setup.ts
@@ -1,6 +1,7 @@
/// <reference types="vitest/globals" />
import '@testing-library/jest-dom/vitest';
+// Clean up localStorage between tests
beforeEach(() => {
localStorage.clear();
});
diff --git a/web/src/theme/ThemeContext.tsx b/web/src/theme/ThemeContext.tsx
new file mode 100644
index 0000000..6bac20e
--- /dev/null
+++ b/web/src/theme/ThemeContext.tsx
@@ -0,0 +1,68 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { createContext, useContext, useState, useEffect, type ReactNode } from
'react';
+
+interface ThemeContextType {
+ darkMode: boolean;
+ setDarkMode: (dark: boolean) => void;
+ toggleTheme: () => void;
+}
+
+const THEME_STORAGE_KEY = 'rocketmq-studio-theme';
+
+const ThemeContext = createContext<ThemeContextType>({
+ darkMode: false,
+ setDarkMode: () => {},
+ toggleTheme: () => {},
+});
+
+/** Read initial theme preference from localStorage, fall back to system
preference. */
+const getInitialDarkMode = (): boolean => {
+ try {
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
+ if (stored !== null) return stored === 'dark';
+ } catch {
+ // localStorage unavailable
+ }
+ return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
+};
+
+export const ThemeProvider = ({ children }: { children: ReactNode }) => {
+ const [darkMode, setDarkMode] = useState<boolean>(getInitialDarkMode);
+
+ // Persist theme choice to localStorage
+ useEffect(() => {
+ try {
+ localStorage.setItem(THEME_STORAGE_KEY, darkMode ? 'dark' : 'light');
+ } catch {
+ // localStorage unavailable
+ }
+ }, [darkMode]);
+
+ const toggleTheme = () => setDarkMode((prev) => !prev);
+
+ return (
+ <ThemeContext.Provider value={{ darkMode, setDarkMode, toggleTheme }}>
+ {children}
+ </ThemeContext.Provider>
+ );
+};
+
+export const useTheme = () => useContext(ThemeContext);
+
+export default ThemeContext;
\ No newline at end of file
diff --git a/web/src/theme/__tests__/ThemeContext.test.tsx
b/web/src/theme/__tests__/ThemeContext.test.tsx
new file mode 100644
index 0000000..3feb0cc
--- /dev/null
+++ b/web/src/theme/__tests__/ThemeContext.test.tsx
@@ -0,0 +1,156 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { ThemeProvider, useTheme } from '../ThemeContext';
+
+/** Helper component that displays theme state and provides toggle button. */
+const ThemeConsumer = () => {
+ const { darkMode, toggleTheme, setDarkMode } = useTheme();
+ return (
+ <div>
+ <span data-testid="mode">{darkMode ? 'dark' : 'light'}</span>
+ <button onClick={toggleTheme}>toggle</button>
+ <button onClick={() => setDarkMode(true)}>set-dark</button>
+ <button onClick={() => setDarkMode(false)}>set-light</button>
+ </div>
+ );
+};
+
+describe('ThemeContext', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it('defaults to light mode when no stored preference', () => {
+ // jsdom does not implement matchMedia – define it to return light
preference
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: vi.fn().mockImplementation((query: string) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: vi.fn(),
+ removeListener: vi.fn(),
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ })),
+ });
+
+ render(
+ <ThemeProvider>
+ <ThemeConsumer />
+ </ThemeProvider>,
+ );
+ expect(screen.getByTestId('mode')).toHaveTextContent('light');
+ });
+
+ it('reads dark mode from localStorage', () => {
+ localStorage.setItem('rocketmq-studio-theme', 'dark');
+
+ render(
+ <ThemeProvider>
+ <ThemeConsumer />
+ </ThemeProvider>,
+ );
+ expect(screen.getByTestId('mode')).toHaveTextContent('dark');
+ });
+
+ it('reads light mode from localStorage', () => {
+ localStorage.setItem('rocketmq-studio-theme', 'light');
+
+ render(
+ <ThemeProvider>
+ <ThemeConsumer />
+ </ThemeProvider>,
+ );
+ expect(screen.getByTestId('mode')).toHaveTextContent('light');
+ });
+
+ it('toggles from light to dark', async () => {
+ const user = userEvent.setup();
+
+ render(
+ <ThemeProvider>
+ <ThemeConsumer />
+ </ThemeProvider>,
+ );
+ expect(screen.getByTestId('mode')).toHaveTextContent('light');
+
+ await user.click(screen.getByText('toggle'));
+ expect(screen.getByTestId('mode')).toHaveTextContent('dark');
+ });
+
+ it('toggles from dark back to light', async () => {
+ localStorage.setItem('rocketmq-studio-theme', 'dark');
+ const user = userEvent.setup();
+
+ render(
+ <ThemeProvider>
+ <ThemeConsumer />
+ </ThemeProvider>,
+ );
+ expect(screen.getByTestId('mode')).toHaveTextContent('dark');
+
+ await user.click(screen.getByText('toggle'));
+ expect(screen.getByTestId('mode')).toHaveTextContent('light');
+ });
+
+ it('persists theme to localStorage after toggle', async () => {
+ const user = userEvent.setup();
+
+ render(
+ <ThemeProvider>
+ <ThemeConsumer />
+ </ThemeProvider>,
+ );
+
+ await user.click(screen.getByText('toggle'));
+ expect(localStorage.getItem('rocketmq-studio-theme')).toBe('dark');
+ });
+
+ it('setDarkMode(true) switches to dark', async () => {
+ const user = userEvent.setup();
+
+ render(
+ <ThemeProvider>
+ <ThemeConsumer />
+ </ThemeProvider>,
+ );
+ expect(screen.getByTestId('mode')).toHaveTextContent('light');
+
+ await user.click(screen.getByText('set-dark'));
+ expect(screen.getByTestId('mode')).toHaveTextContent('dark');
+ });
+
+ it('setDarkMode(false) switches to light', async () => {
+ localStorage.setItem('rocketmq-studio-theme', 'dark');
+ const user = userEvent.setup();
+
+ render(
+ <ThemeProvider>
+ <ThemeConsumer />
+ </ThemeProvider>,
+ );
+
+ await user.click(screen.getByText('set-light'));
+ expect(screen.getByTestId('mode')).toHaveTextContent('light');
+ });
+});
\ No newline at end of file