Skip to content

Commit 6f3b7d0

Browse files
committed
[grid] UI Light/Dark Mode Toggle
Signed-off-by: Viet Nguyen Duc <[email protected]>
1 parent db05817 commit 6f3b7d0

File tree

10 files changed

+387
-34
lines changed

10 files changed

+387
-34
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: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
const saved = localStorage.getItem('theme-mode') as ThemeMode
42+
if (saved) setThemeMode(saved)
43+
setSystemPrefersDark(window.matchMedia('(prefers-color-scheme: dark)').matches)
44+
}, [])
45+
46+
useEffect(() => {
47+
localStorage.setItem('theme-mode', themeMode)
48+
}, [themeMode])
49+
50+
useEffect(() => {
51+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
52+
const handler = (e: MediaQueryListEvent) => setSystemPrefersDark(e.matches)
53+
mediaQuery.addEventListener('change', handler)
54+
return () => mediaQuery.removeEventListener('change', handler)
55+
}, [])
56+
57+
const isDark = themeMode === 'dark' || (themeMode === 'system' && systemPrefersDark)
58+
const currentTheme = isDark ? darkTheme : lightTheme
59+
60+
return (
61+
<ThemeContext.Provider value={{ themeMode, setThemeMode }}>
62+
<ThemeProvider theme={currentTheme}>
63+
<CssBaseline />
64+
{children}
65+
</ThemeProvider>
66+
</ThemeContext.Provider>
67+
)
68+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 { useState, useEffect } from 'react'
19+
import { lightTheme, darkTheme } from '../theme/themes'
20+
21+
type ThemeMode = 'light' | 'dark' | 'system'
22+
23+
export const useTheme = () => {
24+
const [themeMode, setThemeMode] = useState<ThemeMode>('system')
25+
const [systemPrefersDark, setSystemPrefersDark] = useState(false)
26+
27+
useEffect(() => {
28+
if (typeof window !== 'undefined') {
29+
const saved = localStorage.getItem('theme-mode') as ThemeMode
30+
if (saved) setThemeMode(saved)
31+
setSystemPrefersDark(window.matchMedia('(prefers-color-scheme: dark)').matches)
32+
}
33+
}, [])
34+
35+
36+
37+
useEffect(() => {
38+
if (typeof window !== 'undefined') {
39+
localStorage.setItem('theme-mode', themeMode)
40+
}
41+
}, [themeMode])
42+
43+
useEffect(() => {
44+
if (typeof window !== 'undefined') {
45+
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)')
46+
const handler = (e: MediaQueryListEvent) => setSystemPrefersDark(e.matches)
47+
mediaQuery.addEventListener('change', handler)
48+
return () => mediaQuery.removeEventListener('change', handler)
49+
}
50+
}, [])
51+
52+
const isDark = themeMode === 'dark' || (themeMode === 'system' && systemPrefersDark)
53+
const currentTheme = isDark ? darkTheme : lightTheme
54+
55+
return {
56+
themeMode,
57+
setThemeMode,
58+
currentTheme,
59+
isDark
60+
}
61+
}

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: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
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
46+
expect(button).toHaveAttribute('aria-label', 'Toggle theme')
47+
48+
// Click to light mode
49+
fireEvent.click(button)
50+
51+
// Click to dark mode
52+
fireEvent.click(button)
53+
54+
// Click back to system mode
55+
fireEvent.click(button)
56+
})
57+
58+
it('responds to system preference changes', () => {
59+
const listeners: Array<(e: any) => void> = []
60+
const mockMediaQuery = {
61+
matches: false,
62+
addEventListener: jest.fn((_, handler) => listeners.push(handler)),
63+
removeEventListener: jest.fn()
64+
}
65+
66+
window.matchMedia = jest.fn(() => mockMediaQuery)
67+
68+
render(
69+
<CustomThemeProvider>
70+
<ThemeToggle />
71+
</CustomThemeProvider>
72+
)
73+
74+
// Simulate system preference change
75+
listeners.forEach(listener => listener({ matches: true }))
76+
77+
expect(mockMediaQuery.addEventListener).toHaveBeenCalledWith('change', expect.any(Function))
78+
})

0 commit comments

Comments
 (0)