diff --git a/packages/documentation-framework/components/example/example.js b/packages/documentation-framework/components/example/example.js index ca9e08197e..e6f794ea66 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,89 @@ 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.LIGHT: + return 'Light'; + case THEME_MODES.DARK: + return 'Dark'; + default: + return 'System'; + } + }; + + const getThemeIcon = (mode) => { + switch (mode) { + case THEME_MODES.LIGHT: + return ; + case THEME_MODES.DARK: + return ; + default: + return ; + } + }; + + return ( + + ); +}; + class ErrorBoundary extends React.Component { constructor(props) { super(props); @@ -199,21 +286,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) => { + // SSR-safe check for window and matchMedia + if (typeof window === 'undefined' || !window.matchMedia) { + return 'light'; + } + + if (mode === THEME_MODES.SYSTEM) { + try { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } catch (error) { + // Fallback if matchMedia fails + console.warn('matchMedia not supported, defaulting to light theme'); + return 'light'; + } + } + return mode; + }; + + const updateThemeClass = (resolvedTheme) => { + if (typeof window === 'undefined' || !document) { + return; + } + + const htmlElement = document.querySelector('html'); + if (!htmlElement) { + return; + } + + 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(() => { + // Enhanced SSR-safe check + if (typeof window === 'undefined' || !window.matchMedia) { + return; + } + + let mediaQuery; + try { + mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + } catch (error) { + console.warn('matchMedia not supported, skipping system theme detection'); + return; + } + + const handleSystemThemeChange = (e) => { + if (themeMode === THEME_MODES.SYSTEM) { + const newSystemTheme = e.matches ? 'dark' : 'light'; + setResolvedTheme(newSystemTheme); + updateThemeClass(newSystemTheme); + } + }; + + // Check if addEventListener is available (some older browsers might not support it) + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener('change', handleSystemThemeChange); + return () => { + mediaQuery.removeEventListener('change', handleSystemThemeChange); + }; + } else if (mediaQuery.addListener) { + // Fallback for older browsers + mediaQuery.addListener(handleSystemThemeChange); + return () => { + mediaQuery.removeListener(handleSystemThemeChange); + }; + } + }, [themeMode]); + + // Initial theme application + useEffect(() => { + const initialResolvedTheme = getResolvedTheme(themeMode); + setResolvedTheme(initialResolvedTheme); + updateThemeClass(initialResolvedTheme); + }, [themeMode]); + + return { + themeMode, + setThemeMode, + resolvedTheme, + 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..fb72522a80 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,31 @@ 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.LIGHT: + return 'Light'; + case THEME_MODES.DARK: + return 'Dark'; + default: + return 'System'; + } + }; + + const getThemeIcon = (mode) => { + switch (mode) { + case THEME_MODES.LIGHT: + return ; + case THEME_MODES.DARK: + return ; + default: + return ; + } }; useEffect(() => { @@ -92,24 +118,6 @@ const HeaderTools = ({ )} - {hasDarkThemeSwitcher && ( - - - } - isSelected={!isDarkTheme} - onChange={toggleDarkTheme} - /> - } - isSelected={isDarkTheme} - onChange={toggleDarkTheme} - /> - - - )} {hasRTLSwitcher && ( + {hasDarkThemeSwitcher && ( + + + + )} {hasVersionSwitcher && ( { if (typeof window === 'undefined') { @@ -338,8 +394,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 +417,7 @@ export const SideNavLayout = ({ children, groupedRoutes, navOpen: navOpenProp }) defaultManagedSidebarIsOpen={navOpenProp} > {children} - {process.env.hasFooter &&