Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions javascript/grid-ui/src/components/ThemeToggle/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -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 <LightMode />
if (themeMode === 'dark') return <DarkMode />
return <AutoMode />
}

const getTooltip = () => {
if (themeMode === 'light') return 'Switch to dark mode'
if (themeMode === 'dark') return 'Switch to system mode'
return 'Switch to light mode'
}

return (
<Tooltip title={getTooltip()}>
<IconButton
color="inherit"
onClick={handleClick}
aria-label="Toggle theme"
>
{getIcon()}
</IconButton>
</Tooltip>
)
}
5 changes: 4 additions & 1 deletion javascript/grid-ui/src/components/TopBar/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
</Typography>
<Typography variant="body2">
<Typography variant="body2" sx={{ color: (theme) => theme.palette.mode === 'dark' ? 'primary.main' : 'inherit' }}>
{subheader}
</Typography>
</Box>
</Box>
<ThemeToggle />
</Toolbar>
</AppBar>
</Box>
Expand Down
76 changes: 76 additions & 0 deletions javascript/grid-ui/src/contexts/ThemeContext.tsx
Original file line number Diff line number Diff line change
@@ -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<ThemeMode>('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 (
<ThemeContext.Provider value={{ themeMode, setThemeMode }}>
<ThemeProvider theme={currentTheme}>
<CssBaseline />
{children}
</ThemeProvider>
</ThemeContext.Provider>
)
}
10 changes: 4 additions & 6 deletions javascript/grid-ui/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -32,12 +31,11 @@ const root = ReactDOM.createRoot(
root.render(
<React.StrictMode>
<StyledEngineProvider injectFirst>
<ThemeProvider theme={theme}>
<CssBaseline/>
<CustomThemeProvider>
<Router>
<App/>
</Router>
</ThemeProvider>
</CustomThemeProvider>
</StyledEngineProvider>
</React.StrictMode>
)
Expand Down
25 changes: 25 additions & 0 deletions javascript/grid-ui/src/tests/__mocks__/useTheme.tsx
Original file line number Diff line number Diff line change
@@ -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
}))
85 changes: 85 additions & 0 deletions javascript/grid-ui/src/tests/components/ThemeToggle.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<CustomThemeProvider>
<ThemeToggle />
</CustomThemeProvider>
)

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(
<CustomThemeProvider>
<ThemeToggle />
</CustomThemeProvider>
)

// Simulate system preference change
listeners.forEach(listener => listener({ matches: true }))

expect(mockMediaQuery.addEventListener).toHaveBeenCalledWith('change', expect.any(Function))
})
52 changes: 40 additions & 12 deletions javascript/grid-ui/src/tests/components/TopBar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<TopBar subheader={subheaderText} drawerOpen
toggleDrawer={handleClick}/>)
render(
<CustomThemeProvider>
<TopBar subheader={subheaderText} drawerOpen toggleDrawer={handleClick}/>
</CustomThemeProvider>
)
expect(screen.getByText('Selenium Grid')).toBeInTheDocument()
expect(screen.getByRole('img')).toHaveAttribute('alt', 'Selenium Grid Logo')
expect(screen.getByText(subheaderText)).toBeInTheDocument()
Expand All @@ -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(<TopBar subheader="4.0.0" drawerOpen toggleDrawer={handleClick}/>)
const button = screen.getByRole('button')
expect(button.getAttribute('aria-label')).toBe('close drawer')
await user.click(button)
render(
<CustomThemeProvider>
<TopBar subheader="4.0.0" drawerOpen toggleDrawer={handleClick}/>
</CustomThemeProvider>
)
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(<TopBar subheader="4.0.0" toggleDrawer={handleClick}/>)
const button = screen.getByRole('button')
expect(button.getAttribute('aria-label')).toBe('open drawer')
await user.click(button)
render(
<CustomThemeProvider>
<TopBar subheader="4.0.0" toggleDrawer={handleClick}/>
</CustomThemeProvider>
)
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(<TopBar subheader="4.0.0" error toggleDrawer={handleClick}/>)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
render(
<CustomThemeProvider>
<TopBar subheader="4.0.0" error toggleDrawer={handleClick}/>
</CustomThemeProvider>
)
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)
Expand Down
Loading
Loading