Skip to content

Commit 6061c87

Browse files
authored
[grid] UI Light/Dark Mode Toggle (#16364)
* [grid] UI Light/Dark Mode Toggle * Fix review comment --------- Signed-off-by: Viet Nguyen Duc <[email protected]>
1 parent a578a47 commit 6061c87

File tree

9 files changed

+363
-39
lines changed

9 files changed

+363
-39
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Licensed to the Software Freedom Conservancy (SFC) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The SFC licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
import React from 'react'
19+
import { IconButton, Tooltip } from '@mui/material'
20+
import { LightMode, DarkMode, AutoMode } from '@mui/icons-material'
21+
import { useTheme } from '../../contexts/ThemeContext'
22+
23+
export const ThemeToggle: React.FC = () => {
24+
const { themeMode, setThemeMode } = useTheme()
25+
26+
const handleClick = () => {
27+
const nextMode = themeMode === 'light' ? 'dark' : themeMode === 'dark' ? 'system' : 'light'
28+
setThemeMode(nextMode)
29+
}
30+
31+
const getIcon = () => {
32+
if (themeMode === 'light') return <LightMode />
33+
if (themeMode === 'dark') return <DarkMode />
34+
return <AutoMode />
35+
}
36+
37+
const getTooltip = () => {
38+
if (themeMode === 'light') return 'Switch to dark mode'
39+
if (themeMode === 'dark') return 'Switch to system mode'
40+
return 'Switch to light mode'
41+
}
42+
43+
return (
44+
<Tooltip title={getTooltip()}>
45+
<IconButton
46+
color="inherit"
47+
onClick={handleClick}
48+
aria-label="Toggle theme"
49+
>
50+
{getIcon()}
51+
</IconButton>
52+
</Tooltip>
53+
)
54+
}

javascript/grid-ui/src/components/TopBar/TopBar.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { Menu as MenuIcon } from '@mui/icons-material'
2727
import { Help as HelpIcon } from '@mui/icons-material'
2828
import React from 'react'
2929
import seleniumGridLogo from '../../assets/selenium-grid-logo.svg'
30+
import { ThemeToggle } from '../ThemeToggle/ThemeToggle'
3031

3132
const AppBar = styled(MuiAppBar)(({ theme }) => ({
3233
zIndex: theme.zIndex.drawer + 1,
@@ -93,14 +94,16 @@ function TopBar (props): JSX.Element {
9394
component="h1"
9495
variant="h4"
9596
noWrap
97+
sx={{ color: (theme) => theme.palette.mode === 'dark' ? 'primary.main' : 'inherit' }}
9698
>
9799
Selenium Grid
98100
</Typography>
99-
<Typography variant="body2">
101+
<Typography variant="body2" sx={{ color: (theme) => theme.palette.mode === 'dark' ? 'primary.main' : 'inherit' }}>
100102
{subheader}
101103
</Typography>
102104
</Box>
103105
</Box>
106+
<ThemeToggle />
104107
</Toolbar>
105108
</AppBar>
106109
</Box>
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
// Licensed to the Software Freedom Conservancy (SFC) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The SFC licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
19+
import React, { createContext, useContext, useState, useEffect } from 'react'
20+
import { ThemeProvider } from '@mui/material/styles'
21+
import { CssBaseline } from '@mui/material'
22+
import { lightTheme, darkTheme } from '../theme/themes'
23+
24+
type ThemeMode = 'light' | 'dark' | 'system'
25+
26+
const ThemeContext = createContext<{
27+
themeMode: ThemeMode
28+
setThemeMode: (mode: ThemeMode) => void
29+
}>({
30+
themeMode: 'system',
31+
setThemeMode: () => {}
32+
})
33+
34+
export const useTheme = () => useContext(ThemeContext)
35+
36+
export const CustomThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
37+
const [themeMode, setThemeMode] = useState<ThemeMode>('system')
38+
const [systemPrefersDark, setSystemPrefersDark] = useState(false)
39+
40+
useEffect(() => {
41+
if (typeof window !== 'undefined' && window.localStorage) {
42+
const saved = localStorage.getItem('theme-mode') as ThemeMode
43+
if (saved) setThemeMode(saved)
44+
}
45+
if (typeof window !== 'undefined' && window.matchMedia) {
46+
setSystemPrefersDark(window.matchMedia('(prefers-color-scheme: dark)').matches)
47+
}
48+
}, [])
49+
50+
useEffect(() => {
51+
if (typeof window !== 'undefined' && window.localStorage) {
52+
localStorage.setItem('theme-mode', themeMode)
53+
}
54+
}, [themeMode])
55+
56+
useEffect(() => {
57+
if (typeof window !== 'undefined' && window.matchMedia) {
58+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
59+
const handler = (e: MediaQueryListEvent) => setSystemPrefersDark(e.matches)
60+
mediaQuery.addEventListener('change', handler)
61+
return () => mediaQuery.removeEventListener('change', handler)
62+
}
63+
}, [])
64+
65+
const isDark = themeMode === 'dark' || (themeMode === 'system' && systemPrefersDark)
66+
const currentTheme = isDark ? darkTheme : lightTheme
67+
68+
return (
69+
<ThemeContext.Provider value={{ themeMode, setThemeMode }}>
70+
<ThemeProvider theme={currentTheme}>
71+
<CssBaseline />
72+
{children}
73+
</ThemeProvider>
74+
</ThemeContext.Provider>
75+
)
76+
}

javascript/grid-ui/src/index.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,13 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
import { CssBaseline } from '@mui/material'
19-
import { StyledEngineProvider, ThemeProvider } from '@mui/material/styles'
18+
import { StyledEngineProvider } from '@mui/material/styles'
2019
import React from 'react'
2120
import ReactDOM from 'react-dom/client'
2221
import { HashRouter as Router } from 'react-router-dom'
2322
import App from './App'
2423
import * as serviceWorker from './serviceWorker'
25-
import theme from './theme/theme'
24+
import { CustomThemeProvider } from './contexts/ThemeContext'
2625
import './index.css'
2726

2827
const root = ReactDOM.createRoot(
@@ -32,12 +31,11 @@ const root = ReactDOM.createRoot(
3231
root.render(
3332
<React.StrictMode>
3433
<StyledEngineProvider injectFirst>
35-
<ThemeProvider theme={theme}>
36-
<CssBaseline/>
34+
<CustomThemeProvider>
3735
<Router>
3836
<App/>
3937
</Router>
40-
</ThemeProvider>
38+
</CustomThemeProvider>
4139
</StyledEngineProvider>
4240
</React.StrictMode>
4341
)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Licensed to the Software Freedom Conservancy (SFC) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The SFC licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
import { lightTheme } from '../../theme/themes'
19+
20+
export const useTheme = jest.fn(() => ({
21+
themeMode: 'light',
22+
setThemeMode: jest.fn(),
23+
currentTheme: lightTheme,
24+
isDark: false
25+
}))
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
// Licensed to the Software Freedom Conservancy (SFC) under one
2+
// or more contributor license agreements. See the NOTICE file
3+
// distributed with this work for additional information
4+
// regarding copyright ownership. The SFC licenses this file
5+
// to you under the Apache License, Version 2.0 (the
6+
// "License"); you may not use this file except in compliance
7+
// with the License. You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing,
12+
// software distributed under the License is distributed on an
13+
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14+
// KIND, either express or implied. See the License for the
15+
// specific language governing permissions and limitations
16+
// under the License.
17+
18+
import React from 'react'
19+
import { render, screen, fireEvent } from '@testing-library/react'
20+
import { ThemeToggle } from '../../components/ThemeToggle/ThemeToggle'
21+
import { CustomThemeProvider } from '../../contexts/ThemeContext'
22+
23+
const mockMatchMedia = (matches: boolean) => ({
24+
matches,
25+
addEventListener: jest.fn(),
26+
removeEventListener: jest.fn()
27+
})
28+
29+
beforeEach(() => {
30+
Object.defineProperty(window, 'matchMedia', {
31+
writable: true,
32+
value: jest.fn().mockImplementation(() => mockMatchMedia(false))
33+
})
34+
})
35+
36+
it('cycles through theme modes on click', () => {
37+
render(
38+
<CustomThemeProvider>
39+
<ThemeToggle />
40+
</CustomThemeProvider>
41+
)
42+
43+
const button = screen.getByRole('button')
44+
45+
// Should start with system mode (AutoMode icon)
46+
expect(button).toHaveAttribute('aria-label', 'Toggle theme')
47+
expect(screen.getByTestId('AutoModeIcon')).toBeInTheDocument()
48+
49+
// Click to light mode
50+
fireEvent.click(button)
51+
expect(screen.getByTestId('LightModeIcon')).toBeInTheDocument()
52+
expect(screen.queryByTestId('AutoModeIcon')).not.toBeInTheDocument()
53+
54+
// Click to dark mode
55+
fireEvent.click(button)
56+
expect(screen.getByTestId('DarkModeIcon')).toBeInTheDocument()
57+
expect(screen.queryByTestId('LightModeIcon')).not.toBeInTheDocument()
58+
59+
// Click back to system mode
60+
fireEvent.click(button)
61+
expect(screen.getByTestId('AutoModeIcon')).toBeInTheDocument()
62+
expect(screen.queryByTestId('DarkModeIcon')).not.toBeInTheDocument()
63+
})
64+
65+
it('responds to system preference changes', () => {
66+
const listeners: Array<(e: any) => void> = []
67+
const mockMediaQuery = {
68+
matches: false,
69+
addEventListener: jest.fn((_, handler) => listeners.push(handler)),
70+
removeEventListener: jest.fn()
71+
}
72+
73+
window.matchMedia = jest.fn(() => mockMediaQuery)
74+
75+
render(
76+
<CustomThemeProvider>
77+
<ThemeToggle />
78+
</CustomThemeProvider>
79+
)
80+
81+
// Simulate system preference change
82+
listeners.forEach(listener => listener({ matches: true }))
83+
84+
expect(mockMediaQuery.addEventListener).toHaveBeenCalledWith('change', expect.any(Function))
85+
})

javascript/grid-ui/src/tests/components/TopBar.test.tsx

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,29 @@ import * as React from 'react'
1919
import TopBar from '../../components/TopBar/TopBar'
2020
import { render, screen } from '@testing-library/react'
2121
import userEvent from '@testing-library/user-event'
22+
import { CustomThemeProvider } from '../../contexts/ThemeContext'
2223

2324
const user = userEvent.setup()
2425

26+
beforeEach(() => {
27+
Object.defineProperty(window, 'matchMedia', {
28+
writable: true,
29+
value: jest.fn().mockImplementation(() => ({
30+
matches: false,
31+
addEventListener: jest.fn(),
32+
removeEventListener: jest.fn()
33+
}))
34+
})
35+
})
36+
2537
it('renders basic information', () => {
2638
const subheaderText = 'Hello, world!'
2739
const handleClick = jest.fn()
28-
render(<TopBar subheader={subheaderText} drawerOpen
29-
toggleDrawer={handleClick}/>)
40+
render(
41+
<CustomThemeProvider>
42+
<TopBar subheader={subheaderText} drawerOpen toggleDrawer={handleClick}/>
43+
</CustomThemeProvider>
44+
)
3045
expect(screen.getByText('Selenium Grid')).toBeInTheDocument()
3146
expect(screen.getByRole('img')).toHaveAttribute('alt', 'Selenium Grid Logo')
3247
expect(screen.getByText(subheaderText)).toBeInTheDocument()
@@ -35,27 +50,40 @@ it('renders basic information', () => {
3550
it('can toggle drawer if error flag is not set and the drawer is open',
3651
async () => {
3752
const handleClick = jest.fn()
38-
render(<TopBar subheader="4.0.0" drawerOpen toggleDrawer={handleClick}/>)
39-
const button = screen.getByRole('button')
40-
expect(button.getAttribute('aria-label')).toBe('close drawer')
41-
await user.click(button)
53+
render(
54+
<CustomThemeProvider>
55+
<TopBar subheader="4.0.0" drawerOpen toggleDrawer={handleClick}/>
56+
</CustomThemeProvider>
57+
)
58+
const drawerButton = screen.getByLabelText('close drawer')
59+
expect(drawerButton.getAttribute('aria-label')).toBe('close drawer')
60+
await user.click(drawerButton)
4261
expect(handleClick).toHaveBeenCalledTimes(1)
4362
})
4463

4564
it('can toggle drawer if error flag is not set and the drawer is closed',
4665
async () => {
4766
const handleClick = jest.fn()
48-
render(<TopBar subheader="4.0.0" toggleDrawer={handleClick}/>)
49-
const button = screen.getByRole('button')
50-
expect(button.getAttribute('aria-label')).toBe('open drawer')
51-
await user.click(button)
67+
render(
68+
<CustomThemeProvider>
69+
<TopBar subheader="4.0.0" toggleDrawer={handleClick}/>
70+
</CustomThemeProvider>
71+
)
72+
const drawerButton = screen.getByLabelText('open drawer')
73+
expect(drawerButton.getAttribute('aria-label')).toBe('open drawer')
74+
await user.click(drawerButton)
5275
expect(handleClick).toHaveBeenCalledTimes(1)
5376
})
5477

5578
it('should not toggle drawer if error flag is set', async () => {
5679
const handleClick = jest.fn()
57-
render(<TopBar subheader="4.0.0" error toggleDrawer={handleClick}/>)
58-
expect(screen.queryByRole('button')).not.toBeInTheDocument()
80+
render(
81+
<CustomThemeProvider>
82+
<TopBar subheader="4.0.0" error toggleDrawer={handleClick}/>
83+
</CustomThemeProvider>
84+
)
85+
expect(screen.queryByLabelText('close drawer')).not.toBeInTheDocument()
86+
expect(screen.queryByLabelText('open drawer')).not.toBeInTheDocument()
5987
const link = screen.getByRole('link')
6088
expect(link.getAttribute('href')).toBe('#help')
6189
await user.click(link)

0 commit comments

Comments
 (0)