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)'
+ }
+ }
+ }
+ }
+})