From 7e95d45036387ff4f5364403fe1ea0ef7459fd54 Mon Sep 17 00:00:00 2001 From: David Vail Date: Wed, 26 Feb 2025 15:34:26 -0500 Subject: [PATCH 1/2] Add light/dark mode support --- ui/index.html | 2 +- ui/package.json | 1 + ui/src/components/ThemeToggleButton.tsx | 19 ++++++ ui/src/containers/App.tsx | 29 ++++---- ui/src/containers/AppHeader/AppHeader.tsx | 4 ++ ui/src/infra.css | 4 ++ ui/src/utils/ThemeProvider.tsx | 82 +++++++++++++++++++++++ ui/yarn.lock | 34 +++++++++- 8 files changed, 160 insertions(+), 15 deletions(-) create mode 100644 ui/src/components/ThemeToggleButton.tsx create mode 100644 ui/src/utils/ThemeProvider.tsx diff --git a/ui/index.html b/ui/index.html index 04ffe991a..e301cf912 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1,5 +1,5 @@ - + diff --git a/ui/package.json b/ui/package.json index 7e18426d5..eff0e32de 100644 --- a/ui/package.json +++ b/ui/package.json @@ -30,6 +30,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^8.0.0", + "react-responsive": "^10.0.0", "react-router-dom": "^6.29.0", "yup": "^1.3.3" }, diff --git a/ui/src/components/ThemeToggleButton.tsx b/ui/src/components/ThemeToggleButton.tsx new file mode 100644 index 000000000..e1644b538 --- /dev/null +++ b/ui/src/components/ThemeToggleButton.tsx @@ -0,0 +1,19 @@ +import React, { ReactElement } from 'react'; +import { Button, Tooltip } from '@patternfly/react-core'; +import { useTheme } from 'utils/ThemeProvider'; +import { MoonIcon, SunIcon } from '@patternfly/react-icons'; + +const ThemeToggleButton = (): ReactElement => { + const themeState = useTheme(); + const tooltipText = themeState.isDarkMode ? 'Switch to Light Mode' : 'Switch to Dark Mode'; + + return ( + {tooltipText}} position="bottom"> + + + ); +}; + +export default ThemeToggleButton; diff --git a/ui/src/containers/App.tsx b/ui/src/containers/App.tsx index 909ded810..4e4ef6609 100644 --- a/ui/src/containers/App.tsx +++ b/ui/src/containers/App.tsx @@ -10,6 +10,7 @@ import DownloadsPage from 'containers/DownloadsPage'; import LaunchClusterPage from 'containers/LaunchClusterPage'; import ClusterInfoPage from 'containers/ClusterInfoPage'; import FourOhFour from 'components/FourOhFour'; +import { ThemeProvider } from 'utils/ThemeProvider'; const queryClient = new QueryClient({ defaultOptions: { @@ -34,19 +35,21 @@ function AppRoutes(): ReactElement { export default function App(): ReactElement { return ( - - - - }> - - - - - + + + + + }> + + + + + + ); } diff --git a/ui/src/containers/AppHeader/AppHeader.tsx b/ui/src/containers/AppHeader/AppHeader.tsx index 8aad49fca..8276e63ae 100644 --- a/ui/src/containers/AppHeader/AppHeader.tsx +++ b/ui/src/containers/AppHeader/AppHeader.tsx @@ -7,6 +7,7 @@ import { OutlinedHandPointRightIcon, TerminalIcon } from '@patternfly/react-icon import AppHeaderLayout from 'components/AppHeaderLayout'; import RHACSLogo from 'components/RHACSLogo'; import { useUserAuth } from 'containers/UserAuthProvider'; +import ThemeToggleButton from 'components/ThemeToggleButton'; export default function AppHeader(): ReactElement { const { user, logout } = useUserAuth(); @@ -28,6 +29,9 @@ export default function AppHeader(): ReactElement { } ending={ +
+ +
{user?.Picture ? ( ) : ( diff --git a/ui/src/infra.css b/ui/src/infra.css index 2448605d6..36e3975de 100644 --- a/ui/src/infra.css +++ b/ui/src/infra.css @@ -14,6 +14,10 @@ fill: #000000; } +html.pf-v6-theme-dark .rhacs-logo-text { + fill: #ffffff; +} + /* Form help popovers - rendered with react-markdown */ /* #, ##, ### */ diff --git a/ui/src/utils/ThemeProvider.tsx b/ui/src/utils/ThemeProvider.tsx new file mode 100644 index 000000000..987426baf --- /dev/null +++ b/ui/src/utils/ThemeProvider.tsx @@ -0,0 +1,82 @@ +import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { useMediaQuery } from 'react-responsive'; + +const defaultContextData = { + isDarkMode: false, + toggle: () => {}, +}; + +export const ThemeContext = createContext(defaultContextData); +export const useTheme = () => useContext(ThemeContext); + +const DARK_MODE_KEY = 'isDarkMode'; + +type ThemeState = { + isDarkMode: boolean; + hasThemeMounted: boolean; +}; + +// custom react hook to toggle dark mode across UI +function useEffectDarkMode(): [ThemeState, (next: ThemeState) => void] { + const userPrefersDarkMode = useMediaQuery({ query: '(prefers-color-scheme: dark)' }); + const [themeState, setThemeState] = useState({ + isDarkMode: userPrefersDarkMode, + hasThemeMounted: false, + }); + useEffect(() => { + const darkModeValue = localStorage.getItem(DARK_MODE_KEY); + let isDarkMode; + // In the very beginning, default to using what the user prefers. + if (darkModeValue === null) { + isDarkMode = userPrefersDarkMode; + } else { + // It's always either 'true' or 'false', but if it's something unexpected, + // default to light mode. + isDarkMode = darkModeValue === 'true'; + } + + setThemeState({ isDarkMode, hasThemeMounted: true }); + }, [userPrefersDarkMode]); + + return [themeState, setThemeState]; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [themeState, setThemeState] = useEffectDarkMode(); + + // to prevent theme flicker while getting theme from localStorage + if (!themeState.hasThemeMounted) { + return
; + } + + if (themeState.isDarkMode) { + document.documentElement.classList.add('pf-v6-theme-dark'); + } else { + document.documentElement.classList.remove('pf-v6-theme-dark'); + } + + const toggle = () => { + const darkModeToggled = !themeState.isDarkMode; + + localStorage.setItem(DARK_MODE_KEY, JSON.stringify(darkModeToggled)); + + if (themeState.isDarkMode) { + document.documentElement.classList.add('pf-v6-theme-dark'); + } else { + document.documentElement.classList.remove('pf-v6-theme-dark'); + } + + setThemeState({ ...themeState, isDarkMode: darkModeToggled }); + }; + + return ( + + {children} + + ); +} diff --git a/ui/yarn.lock b/ui/yarn.lock index 68b04a41c..48b35b54e 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -2405,6 +2405,11 @@ cross-spawn@^7.0.2: shebang-command "^2.0.0" which "^2.0.1" +css-mediaquery@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/css-mediaquery/-/css-mediaquery-0.1.2.tgz#6a2c37344928618631c54bd33cedd301da18bea0" + integrity sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q== + css.escape@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/css.escape/-/css.escape-1.5.1.tgz#42e27d4fa04ae32f931a4b4d4191fa9cddee97cb" @@ -3556,6 +3561,11 @@ http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" +hyphenate-style-name@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz#1797bf50369588b47b72ca6d5e65374607cf4436" + integrity sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw== + ignore@^4.0.6: version "4.0.6" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" @@ -4088,6 +4098,13 @@ lz-string@^1.4.4: resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY= +matchmediaquery@^0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/matchmediaquery/-/matchmediaquery-0.4.2.tgz#22582bd4ae63ad9f54c53001bba80cbed0f7eafa" + integrity sha512-wrZpoT50ehYOudhDjt/YvUJc6eUzcdFPdmbizfgvswCKNHD1/OBOHYJpHie+HXpu6bSkEGieFMYk6VuutaiRfA== + dependencies: + css-mediaquery "^0.1.2" + math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -4736,7 +4753,7 @@ progress@^2.0.0: resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== -prop-types@^15.0.0, prop-types@^15.8.1: +prop-types@^15.0.0, prop-types@^15.6.1, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -4835,6 +4852,16 @@ react-refresh@^0.14.2: resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.2.tgz#3833da01ce32da470f1f936b9d477da5c7028bf9" integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== +react-responsive@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/react-responsive/-/react-responsive-10.0.0.tgz#657c7a90823cd565f43aa5918bd8eb0cd2c91c91" + integrity sha512-N6/UiRLGQyGUqrarhBZmrSmHi2FXSD++N5VbSKsBBvWfG0ZV7asvUBluSv5lSzdMyEVjzZ6Y8DL4OHABiztDOg== + dependencies: + hyphenate-style-name "^1.0.0" + matchmediaquery "^0.4.2" + prop-types "^15.6.1" + shallow-equal "^3.1.0" + react-router-dom@^6.29.0: version "6.29.0" resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.29.0.tgz#2ffb56b03ef3d6d6daafcfad9f3922132d2ced94" @@ -5165,6 +5192,11 @@ set-proto@^1.0.0: es-errors "^1.3.0" es-object-atoms "^1.0.0" +shallow-equal@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/shallow-equal/-/shallow-equal-3.1.0.tgz#e7a54bac629c7f248eff6c2f5b63122ba4320bec" + integrity sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg== + shebang-command@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" From 8570e807ecacdf7be4ff3e2927d9d9ac4b66803e Mon Sep 17 00:00:00 2001 From: David Vail Date: Wed, 26 Feb 2025 15:48:50 -0500 Subject: [PATCH 2/2] Delete "tests" --- ui/package.json | 3 +-- ui/src/containers/AppHeader/AppHeader.test.tsx | 15 --------------- 2 files changed, 1 insertion(+), 17 deletions(-) delete mode 100644 ui/src/containers/AppHeader/AppHeader.test.tsx diff --git a/ui/package.json b/ui/package.json index eff0e32de..b68ea62bf 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,8 +5,7 @@ "scripts": { "start": "PORT=3001 HTTPS=true NODE_OPTIONS=--openssl-legacy-provider EXTEND_ESLINT=true vite", "build": "NODE_OPTIONS=--openssl-legacy-provider EXTEND_ESLINT=true vite build", - "test": "react-scripts test", - "eject": "react-scripts eject", + "test": "echo 'Someone should write some tests'", "lint-check": "npm-run-all lint-check:*", "lint-check:non-src": "prettier --check '**/*.{md,css,json}'", "lint-check:src": "eslint --ext .js,.jsx,.ts,.tsx ./", diff --git a/ui/src/containers/AppHeader/AppHeader.test.tsx b/ui/src/containers/AppHeader/AppHeader.test.tsx deleted file mode 100644 index 8f7631f31..000000000 --- a/ui/src/containers/AppHeader/AppHeader.test.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { render } from '@testing-library/react'; -import { BrowserRouter as Router } from 'react-router-dom'; - -import AppHeader from './AppHeader'; - -test('renders app with the proper header', () => { - const { getByText } = render( - - - - ); - const headerElement = getByText('Infra'); - expect(headerElement).toBeInTheDocument(); -});