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 ( - + <> { {/* Theme toggle */}
setDarkMode(!darkMode)} + onClick={toggleTheme} style={{ cursor: 'pointer', display: 'flex', @@ -393,7 +379,7 @@ const MainLayout = () => { ))}
-
+ ); }; 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 ( { ReactDOM.createRoot(document.getElementById('root')!).render( - + + + , ); 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 @@ /// 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({ + 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(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 ( + + {children} + + ); +}; + +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 ( +
+ {darkMode ? 'dark' : 'light'} + + + +
+ ); +}; + +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( + + + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('light'); + }); + + it('reads dark mode from localStorage', () => { + localStorage.setItem('rocketmq-studio-theme', 'dark'); + + render( + + + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('dark'); + }); + + it('reads light mode from localStorage', () => { + localStorage.setItem('rocketmq-studio-theme', 'light'); + + render( + + + , + ); + expect(screen.getByTestId('mode')).toHaveTextContent('light'); + }); + + it('toggles from light to dark', async () => { + const user = userEvent.setup(); + + render( + + + , + ); + 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( + + + , + ); + 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( + + + , + ); + + 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( + + + , + ); + 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( + + + , + ); + + await user.click(screen.getByText('set-light')); + expect(screen.getByTestId('mode')).toHaveTextContent('light'); + }); +}); \ No newline at end of file