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
commit 9d2cc7e5be993a4105929e75087baeeac55d3973 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 025967d3..307eab84 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 b214bbeb..de7dc55b 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 73c687e6..4415ac46 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 3bcbc099..a9f1da51 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 00000000..6bac20e2 --- /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 00000000..3feb0cc6 --- /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
