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

Reply via email to