diff --git a/javascript/grid-ui/src/components/ThemeToggle/ThemeToggle.tsx b/javascript/grid-ui/src/components/ThemeToggle/ThemeToggle.tsx new file mode 100644 index 0000000000000..7d4092c551ac4 --- /dev/null +++ b/javascript/grid-ui/src/components/ThemeToggle/ThemeToggle.tsx @@ -0,0 +1,54 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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 React from 'react' +import { IconButton, Tooltip } from '@mui/material' +import { LightMode, DarkMode, AutoMode } from '@mui/icons-material' +import { useTheme } from '../../contexts/ThemeContext' + +export const ThemeToggle: React.FC = () => { + const { themeMode, setThemeMode } = useTheme() + + const handleClick = () => { + const nextMode = themeMode === 'light' ? 'dark' : themeMode === 'dark' ? 'system' : 'light' + setThemeMode(nextMode) + } + + const getIcon = () => { + if (themeMode === 'light') return + if (themeMode === 'dark') return + return + } + + const getTooltip = () => { + if (themeMode === 'light') return 'Switch to dark mode' + if (themeMode === 'dark') return 'Switch to system mode' + return 'Switch to light mode' + } + + return ( + + + {getIcon()} + + + ) +} \ No newline at end of file diff --git a/javascript/grid-ui/src/components/TopBar/TopBar.tsx b/javascript/grid-ui/src/components/TopBar/TopBar.tsx index dbfdccabc46e7..eb0afc32d0ec0 100644 --- a/javascript/grid-ui/src/components/TopBar/TopBar.tsx +++ b/javascript/grid-ui/src/components/TopBar/TopBar.tsx @@ -27,6 +27,7 @@ import { Menu as MenuIcon } from '@mui/icons-material' import { Help as HelpIcon } from '@mui/icons-material' import React from 'react' import seleniumGridLogo from '../../assets/selenium-grid-logo.svg' +import { ThemeToggle } from '../ThemeToggle/ThemeToggle' const AppBar = styled(MuiAppBar)(({ theme }) => ({ zIndex: theme.zIndex.drawer + 1, @@ -93,14 +94,16 @@ function TopBar (props): JSX.Element { component="h1" variant="h4" noWrap + sx={{ color: (theme) => theme.palette.mode === 'dark' ? 'primary.main' : 'inherit' }} > Selenium Grid - + theme.palette.mode === 'dark' ? 'primary.main' : 'inherit' }}> {subheader} + diff --git a/javascript/grid-ui/src/contexts/ThemeContext.tsx b/javascript/grid-ui/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000000000..a6f37d9ad30cb --- /dev/null +++ b/javascript/grid-ui/src/contexts/ThemeContext.tsx @@ -0,0 +1,76 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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 React, { createContext, useContext, useState, useEffect } from 'react' +import { ThemeProvider } from '@mui/material/styles' +import { CssBaseline } from '@mui/material' +import { lightTheme, darkTheme } from '../theme/themes' + +type ThemeMode = 'light' | 'dark' | 'system' + +const ThemeContext = createContext<{ + themeMode: ThemeMode + setThemeMode: (mode: ThemeMode) => void +}>({ + themeMode: 'system', + setThemeMode: () => {} +}) + +export const useTheme = () => useContext(ThemeContext) + +export const CustomThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [themeMode, setThemeMode] = useState('system') + const [systemPrefersDark, setSystemPrefersDark] = useState(false) + + useEffect(() => { + if (typeof window !== 'undefined' && window.localStorage) { + const saved = localStorage.getItem('theme-mode') as ThemeMode + if (saved) setThemeMode(saved) + } + if (typeof window !== 'undefined' && window.matchMedia) { + setSystemPrefersDark(window.matchMedia('(prefers-color-scheme: dark)').matches) + } + }, []) + + useEffect(() => { + if (typeof window !== 'undefined' && window.localStorage) { + localStorage.setItem('theme-mode', themeMode) + } + }, [themeMode]) + + useEffect(() => { + if (typeof window !== 'undefined' && window.matchMedia) { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)') + const handler = (e: MediaQueryListEvent) => setSystemPrefersDark(e.matches) + mediaQuery.addEventListener('change', handler) + return () => mediaQuery.removeEventListener('change', handler) + } + }, []) + + const isDark = themeMode === 'dark' || (themeMode === 'system' && systemPrefersDark) + const currentTheme = isDark ? darkTheme : lightTheme + + return ( + + + + {children} + + + ) +} diff --git a/javascript/grid-ui/src/index.tsx b/javascript/grid-ui/src/index.tsx index 76a9e3efc4883..cb5511fbf4af5 100644 --- a/javascript/grid-ui/src/index.tsx +++ b/javascript/grid-ui/src/index.tsx @@ -15,14 +15,13 @@ // specific language governing permissions and limitations // under the License. -import { CssBaseline } from '@mui/material' -import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles' +import { StyledEngineProvider } from '@mui/material/styles' import React from 'react' import ReactDOM from 'react-dom/client' import { HashRouter as Router } from 'react-router-dom' import App from './App' import * as serviceWorker from './serviceWorker' -import theme from './theme/theme' +import { CustomThemeProvider } from './contexts/ThemeContext' import './index.css' const root = ReactDOM.createRoot( @@ -32,12 +31,11 @@ const root = ReactDOM.createRoot( root.render( - - + - + ) diff --git a/javascript/grid-ui/src/tests/__mocks__/useTheme.tsx b/javascript/grid-ui/src/tests/__mocks__/useTheme.tsx new file mode 100644 index 0000000000000..82624489198a5 --- /dev/null +++ b/javascript/grid-ui/src/tests/__mocks__/useTheme.tsx @@ -0,0 +1,25 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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 { lightTheme } from '../../theme/themes' + +export const useTheme = jest.fn(() => ({ + themeMode: 'light', + setThemeMode: jest.fn(), + currentTheme: lightTheme, + isDark: false +})) \ No newline at end of file diff --git a/javascript/grid-ui/src/tests/components/ThemeToggle.test.tsx b/javascript/grid-ui/src/tests/components/ThemeToggle.test.tsx new file mode 100644 index 0000000000000..6686817ce5214 --- /dev/null +++ b/javascript/grid-ui/src/tests/components/ThemeToggle.test.tsx @@ -0,0 +1,85 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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 React from 'react' +import { render, screen, fireEvent } from '@testing-library/react' +import { ThemeToggle } from '../../components/ThemeToggle/ThemeToggle' +import { CustomThemeProvider } from '../../contexts/ThemeContext' + +const mockMatchMedia = (matches: boolean) => ({ + matches, + addEventListener: jest.fn(), + removeEventListener: jest.fn() +}) + +beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(() => mockMatchMedia(false)) + }) +}) + +it('cycles through theme modes on click', () => { + render( + + + + ) + + const button = screen.getByRole('button') + + // Should start with system mode (AutoMode icon) + expect(button).toHaveAttribute('aria-label', 'Toggle theme') + expect(screen.getByTestId('AutoModeIcon')).toBeInTheDocument() + + // Click to light mode + fireEvent.click(button) + expect(screen.getByTestId('LightModeIcon')).toBeInTheDocument() + expect(screen.queryByTestId('AutoModeIcon')).not.toBeInTheDocument() + + // Click to dark mode + fireEvent.click(button) + expect(screen.getByTestId('DarkModeIcon')).toBeInTheDocument() + expect(screen.queryByTestId('LightModeIcon')).not.toBeInTheDocument() + + // Click back to system mode + fireEvent.click(button) + expect(screen.getByTestId('AutoModeIcon')).toBeInTheDocument() + expect(screen.queryByTestId('DarkModeIcon')).not.toBeInTheDocument() +}) + +it('responds to system preference changes', () => { + const listeners: Array<(e: any) => void> = [] + const mockMediaQuery = { + matches: false, + addEventListener: jest.fn((_, handler) => listeners.push(handler)), + removeEventListener: jest.fn() + } + + window.matchMedia = jest.fn(() => mockMediaQuery) + + render( + + + + ) + + // Simulate system preference change + listeners.forEach(listener => listener({ matches: true })) + + expect(mockMediaQuery.addEventListener).toHaveBeenCalledWith('change', expect.any(Function)) +}) \ No newline at end of file diff --git a/javascript/grid-ui/src/tests/components/TopBar.test.tsx b/javascript/grid-ui/src/tests/components/TopBar.test.tsx index a7d1c6324c925..196e9006cecec 100644 --- a/javascript/grid-ui/src/tests/components/TopBar.test.tsx +++ b/javascript/grid-ui/src/tests/components/TopBar.test.tsx @@ -19,14 +19,29 @@ import * as React from 'react' import TopBar from '../../components/TopBar/TopBar' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' +import { CustomThemeProvider } from '../../contexts/ThemeContext' const user = userEvent.setup() +beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation(() => ({ + matches: false, + addEventListener: jest.fn(), + removeEventListener: jest.fn() + })) + }) +}) + it('renders basic information', () => { const subheaderText = 'Hello, world!' const handleClick = jest.fn() - render() + render( + + + + ) expect(screen.getByText('Selenium Grid')).toBeInTheDocument() expect(screen.getByRole('img')).toHaveAttribute('alt', 'Selenium Grid Logo') expect(screen.getByText(subheaderText)).toBeInTheDocument() @@ -35,27 +50,40 @@ it('renders basic information', () => { it('can toggle drawer if error flag is not set and the drawer is open', async () => { const handleClick = jest.fn() - render() - const button = screen.getByRole('button') - expect(button.getAttribute('aria-label')).toBe('close drawer') - await user.click(button) + render( + + + + ) + const drawerButton = screen.getByLabelText('close drawer') + expect(drawerButton.getAttribute('aria-label')).toBe('close drawer') + await user.click(drawerButton) expect(handleClick).toHaveBeenCalledTimes(1) }) it('can toggle drawer if error flag is not set and the drawer is closed', async () => { const handleClick = jest.fn() - render() - const button = screen.getByRole('button') - expect(button.getAttribute('aria-label')).toBe('open drawer') - await user.click(button) + render( + + + + ) + const drawerButton = screen.getByLabelText('open drawer') + expect(drawerButton.getAttribute('aria-label')).toBe('open drawer') + await user.click(drawerButton) expect(handleClick).toHaveBeenCalledTimes(1) }) it('should not toggle drawer if error flag is set', async () => { const handleClick = jest.fn() - render() - expect(screen.queryByRole('button')).not.toBeInTheDocument() + render( + + + + ) + expect(screen.queryByLabelText('close drawer')).not.toBeInTheDocument() + expect(screen.queryByLabelText('open drawer')).not.toBeInTheDocument() const link = screen.getByRole('link') expect(link.getAttribute('href')).toBe('#help') await user.click(link) diff --git a/javascript/grid-ui/src/theme/theme.tsx b/javascript/grid-ui/src/theme/theme.tsx index 06453c3ef97b4..39194d1fcd53b 100644 --- a/javascript/grid-ui/src/theme/theme.tsx +++ b/javascript/grid-ui/src/theme/theme.tsx @@ -15,26 +15,9 @@ // specific language governing permissions and limitations // under the License. -import { createTheme, Theme } from '@mui/material/styles' -import typography from './typography' +import { lightTheme } from './themes' -// A custom theme for this app -const theme: Theme = createTheme({ - palette: { - primary: { - main: '#615E9B' - }, - secondary: { - main: '#F7F8F8' - }, - error: { - main: '#FF1744' - }, - background: { - default: '#F7F8F8' - } - }, - typography -}) +// Backward compatibility - export light theme as default +const theme = lightTheme export default theme diff --git a/javascript/grid-ui/src/theme/themes.tsx b/javascript/grid-ui/src/theme/themes.tsx new file mode 100644 index 0000000000000..f88fb80de987e --- /dev/null +++ b/javascript/grid-ui/src/theme/themes.tsx @@ -0,0 +1,72 @@ +// Licensed to the Software Freedom Conservancy (SFC) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The SFC 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 { createTheme, Theme } from '@mui/material/styles' +import typography from './typography' + +export const lightTheme: Theme = createTheme({ + palette: { + mode: 'light', + primary: { + main: '#615E9B' + }, + secondary: { + main: '#F7F8F8' + }, + error: { + main: '#FF1744' + }, + background: { + default: '#F7F8F8' + } + }, + typography +}) + +export const darkTheme: Theme = createTheme({ + palette: { + mode: 'dark', + primary: { + main: '#615E9B' + }, + secondary: { + main: '#36393F' + }, + error: { + main: '#F04747' + }, + background: { + default: '#0c1117', + paper: '#161B22' + }, + text: { + primary: '#F0F6FC', + secondary: '#8B949E' + } + }, + typography, + components: { + MuiAppBar: { + styleOverrides: { + root: { + backgroundColor: '#020408', + boxShadow: '0 1px 3px rgba(0,0,0,0.5)' + } + } + } + } +})