From 1d574c2f2625828c42543eacfbeb610440bd09fc Mon Sep 17 00:00:00 2001 From: Sarah Rambacher Date: Fri, 30 May 2025 16:53:50 -0400 Subject: [PATCH 1/8] feat(theme): implement full-screen theme selector and custom hook for theme management - Added FullScreenThemeSelector component for theme selection with icons. - Introduced useTheme hook to manage theme state and preferences. - Replaced previous dark theme switcher with a dropdown for theme selection in HeaderTools and Example components. - Updated SideNavLayout to utilize the new theme management system. --- .../components/example/example.js | 110 ++++++++++++++--- .../documentation-framework/hooks/useTheme.js | 93 ++++++++++++++ .../layouts/sideNavLayout/sideNavLayout.js | 113 ++++++++++++++---- 3 files changed, 275 insertions(+), 41 deletions(-) create mode 100644 packages/documentation-framework/hooks/useTheme.js diff --git a/packages/documentation-framework/components/example/example.js b/packages/documentation-framework/components/example/example.js index ca9e08197e..63c226ed02 100644 --- a/packages/documentation-framework/components/example/example.js +++ b/packages/documentation-framework/components/example/example.js @@ -1,4 +1,4 @@ -import React, { useContext, useEffect } from 'react'; +import React, { useContext, useEffect, useState, useCallback } from 'react'; import { useLocation } from '@reach/router'; import { Button, @@ -8,6 +8,10 @@ import { debounce, Label, Switch, + Select, + SelectOption, + SelectList, + MenuToggle, Tooltip, Stack, StackItem @@ -19,6 +23,9 @@ import * as reactTableModule from '@patternfly/react-table'; import * as reactTableDeprecatedModule from '@patternfly/react-table/deprecated'; import { css } from '@patternfly/react-styles'; import { getParameters } from 'codesandbox/lib/api/define'; +import SunIcon from '@patternfly/react-icons/dist/esm/icons/sun-icon'; +import MoonIcon from '@patternfly/react-icons/dist/esm/icons/moon-icon'; +import DesktopIcon from '@patternfly/react-icons/dist/esm/icons/desktop-icon'; import { ExampleToolbar } from './exampleToolbar.jsx'; import { AutoLinkHeader } from '../autoLinkHeader/autoLinkHeader'; import { @@ -32,9 +39,93 @@ import { import { convertToReactComponent } from '@patternfly/ast-helpers'; import missingThumbnail from './missing-thumbnail.jpg'; import { RtlContext } from '../../layouts'; +import { useTheme } from '../../hooks/useTheme'; const errorComponent = (err) =>
{err.toString()}
; +// Full-screen theme selector component using shared theme hook +const FullScreenThemeSelector = () => { + const { themeMode, setThemeMode, THEME_MODES } = useTheme(); + const [isThemeSelectOpen, setIsThemeSelectOpen] = useState(false); + + const handleThemeChange = (_event, selectedMode) => { + setThemeMode(selectedMode); + setIsThemeSelectOpen(false); + }; + + const getThemeDisplayText = (mode) => { + switch (mode) { + case THEME_MODES.SYSTEM: + return 'System'; + case THEME_MODES.LIGHT: + return 'Light'; + case THEME_MODES.DARK: + return 'Dark'; + default: + return 'System'; + } + }; + + const getThemeIcon = (mode) => { + switch (mode) { + case THEME_MODES.SYSTEM: + return ; + case THEME_MODES.LIGHT: + return ; + case THEME_MODES.DARK: + return ; + default: + return ; + } + }; + + return ( + + ); +}; + class ErrorBoundary extends React.Component { constructor(props) { super(props); @@ -199,21 +290,10 @@ export const Example = ({ {(hasDarkThemeSwitcher || hasRTLSwitcher) && ( - {hasDarkThemeSwitcher && ( - - document - .querySelector('html') - .classList.toggle('pf-v6-theme-dark') - } - /> - )} + {hasDarkThemeSwitcher && } {hasRTLSwitcher && ( { + const getStoredThemeMode = () => { + if (typeof window === 'undefined' || !window.localStorage) return null; + return localStorage.getItem(THEME_STORAGE_KEY); + }; + + const setStoredThemeMode = (mode) => { + if (typeof window === 'undefined' || !window.localStorage) return; + localStorage.setItem(THEME_STORAGE_KEY, mode); + }; + + const getResolvedTheme = (mode) => { + if (typeof window === 'undefined') return 'light'; + if (mode === THEME_MODES.SYSTEM) { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + return mode; + }; + + const updateThemeClass = (resolvedTheme) => { + if (typeof window === 'undefined') return; + const htmlElement = document.querySelector('html'); + if (resolvedTheme === 'dark') { + htmlElement.classList.add(DARK_MODE_CLASS); + } else { + htmlElement.classList.remove(DARK_MODE_CLASS); + } + }; + + const [themeMode, setThemeModeState] = useState(() => { + const stored = getStoredThemeMode(); + return stored && Object.values(THEME_MODES).includes(stored) ? stored : THEME_MODES.SYSTEM; + }); + + const [resolvedTheme, setResolvedTheme] = useState(() => getResolvedTheme(themeMode)); + + const setThemeMode = useCallback((newMode) => { + setThemeModeState(newMode); + setStoredThemeMode(newMode); + + const newResolvedTheme = getResolvedTheme(newMode); + setResolvedTheme(newResolvedTheme); + updateThemeClass(newResolvedTheme); + }, []); + + // Listen for system preference changes + useEffect(() => { + if (typeof window === 'undefined') return; + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleSystemThemeChange = (e) => { + if (themeMode === THEME_MODES.SYSTEM) { + const newSystemTheme = e.matches ? 'dark' : 'light'; + setResolvedTheme(newSystemTheme); + updateThemeClass(newSystemTheme); + } + }; + + mediaQuery.addEventListener('change', handleSystemThemeChange); + + return () => { + mediaQuery.removeEventListener('change', handleSystemThemeChange); + }; + }, [themeMode]); + + // Initial theme application + useEffect(() => { + const initialResolvedTheme = getResolvedTheme(themeMode); + setResolvedTheme(initialResolvedTheme); + updateThemeClass(initialResolvedTheme); + }, [themeMode]); + + return { + themeMode, + setThemeMode, + resolvedTheme, + THEME_MODES + }; +}; + +export { THEME_MODES }; \ No newline at end of file diff --git a/packages/documentation-framework/layouts/sideNavLayout/sideNavLayout.js b/packages/documentation-framework/layouts/sideNavLayout/sideNavLayout.js index 8540701f4b..05f06f4e0d 100644 --- a/packages/documentation-framework/layouts/sideNavLayout/sideNavLayout.js +++ b/packages/documentation-framework/layouts/sideNavLayout/sideNavLayout.js @@ -23,17 +23,20 @@ import { SkipToContent, Switch, SearchInput, - ToggleGroup, - ToggleGroupItem, + Select, + SelectOption, + SelectList, MastheadLogo } from '@patternfly/react-core'; import BarsIcon from '@patternfly/react-icons/dist/esm/icons/bars-icon'; import GithubIcon from '@patternfly/react-icons/dist/esm/icons/github-icon'; import MoonIcon from '@patternfly/react-icons/dist/esm/icons/moon-icon'; import SunIcon from '@patternfly/react-icons/dist/esm/icons/sun-icon'; +import DesktopIcon from '@patternfly/react-icons/dist/esm/icons/desktop-icon'; import { SideNav, TopNav, GdprBanner } from '../../components'; import staticVersions from '../../versions.json'; import { Footer } from '@patternfly/documentation-framework/components'; +import { useTheme } from '../../hooks/useTheme'; export const RtlContext = createContext(false); @@ -46,8 +49,9 @@ const HeaderTools = ({ topNavItems, isRTL, setIsRTL, - isDarkTheme, - setIsDarkTheme + themeMode, + setThemeMode, + THEME_MODES }) => { const latestVersion = versions.Releases.find((version) => version.latest); const previousReleases = Object.values(versions.Releases).filter((version) => !version.hidden && !version.latest); @@ -55,6 +59,7 @@ const HeaderTools = ({ const [isDropdownOpen, setDropdownOpen] = useState(false); const [searchValue, setSearchValue] = React.useState(''); const [isSearchExpanded, setIsSearchExpanded] = React.useState(false); + const [isThemeSelectOpen, setIsThemeSelectOpen] = useState(false); const getDropdownItem = (version, isLatest = false) => ( @@ -70,10 +75,35 @@ const HeaderTools = ({ setIsSearchExpanded(!isSearchExpanded); }; - const toggleDarkTheme = (_evt, selected) => { - const darkThemeToggleClicked = !selected === isDarkTheme; - document.querySelector('html').classList.toggle('pf-v6-theme-dark', darkThemeToggleClicked); - setIsDarkTheme(darkThemeToggleClicked); + const handleThemeChange = (_event, selectedMode) => { + setThemeMode(selectedMode); + setIsThemeSelectOpen(false); + }; + + const getThemeDisplayText = (mode) => { + switch (mode) { + case THEME_MODES.SYSTEM: + return 'System'; + case THEME_MODES.LIGHT: + return 'Light'; + case THEME_MODES.DARK: + return 'Dark'; + default: + return 'System'; + } + }; + + const getThemeIcon = (mode) => { + switch (mode) { + case THEME_MODES.SYSTEM: + return ; + case THEME_MODES.LIGHT: + return ; + case THEME_MODES.DARK: + return ; + default: + return ; + } }; useEffect(() => { @@ -94,20 +124,49 @@ const HeaderTools = ({ {hasDarkThemeSwitcher && ( - - } - isSelected={!isDarkTheme} - onChange={toggleDarkTheme} - /> - } - isSelected={isDarkTheme} - onChange={toggleDarkTheme} - /> - + )} {hasRTLSwitcher && ( @@ -245,7 +304,8 @@ export const SideNavLayout = ({ children, groupedRoutes, navOpen: navOpenProp }) const [versions, setVersions] = useState({ ...staticVersions }); const [isRTL, setIsRTL] = useState(false); - const [isDarkTheme, setIsDarkTheme] = React.useState(false); + + const { themeMode, setThemeMode, resolvedTheme, THEME_MODES } = useTheme(); useEffect(() => { if (typeof window === 'undefined') { @@ -338,8 +398,9 @@ export const SideNavLayout = ({ children, groupedRoutes, navOpen: navOpenProp }) topNavItems={topNavItems} isRTL={isRTL} setIsRTL={setIsRTL} - isDarkTheme={isDarkTheme} - setIsDarkTheme={setIsDarkTheme} + themeMode={themeMode} + setThemeMode={setThemeMode} + THEME_MODES={THEME_MODES} /> )} @@ -360,7 +421,7 @@ export const SideNavLayout = ({ children, groupedRoutes, navOpen: navOpenProp }) defaultManagedSidebarIsOpen={navOpenProp} > {children} - {process.env.hasFooter &&