diff --git a/packages/dev/s2-docs/pages/s2/home/Header.tsx b/packages/dev/s2-docs/pages/s2/home/Header.tsx index bdf54c63cc2..32a0ef8c907 100644 --- a/packages/dev/s2-docs/pages/s2/home/Header.tsx +++ b/packages/dev/s2-docs/pages/s2/home/Header.tsx @@ -1,14 +1,88 @@ 'use client'; import {CSSProperties, useId, useRef, useState} from 'react'; +import {Button} from 'react-aria-components'; +import Contrast from '@react-spectrum/s2/icons/Contrast'; +import Lighten from '@react-spectrum/s2/icons/Lighten'; +import {Divider, pressScale} from '@react-spectrum/s2'; import SearchMenuTrigger, {preloadSearchMenu} from '@react-spectrum/s2-docs/src/SearchMenuTrigger'; import {useLayoutEffect} from '@react-aria/utils'; +import {useSettings} from '@react-spectrum/s2-docs/src/SettingsContext'; import { HeaderLink, Link } from '@react-spectrum/s2-docs/src/Link'; -import { space, style } from '@react-spectrum/s2/style' with {type: 'macro'}; +import { focusRing, iconStyle, space, style } from '@react-spectrum/s2/style' with {type: 'macro'}; import { getBaseUrl } from '@react-spectrum/s2-docs/src/pageUtils'; import GithubLogo from '@react-spectrum/s2-docs/src/icons/GithubLogo'; import { NpmLogo } from '@react-spectrum/s2-docs/src/icons/NpmLogo'; +const colorSchemeToggleStyles = style({ + ...focusRing(), + outlineColor: 'white', + font: 'ui', + color: { + default: 'white' + }, + textDecoration: 'none', + transition: 'default', + backgroundColor: { + default: 'transparent', + isHovered: 'white/15', + isFocusVisible: 'white/15' + }, + size: 32, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 'lg', + borderWidth: 0 +}); + +const whiteIconStyle = iconStyle({color: 'white'}); + +const iconContainerStyles = style({ + position: 'relative', + size: 20 +}); + +function ColorSchemeToggle() { + let {colorScheme, toggleColorScheme, systemColorScheme} = useSettings(); + let isOverriding = colorScheme !== systemColorScheme; + let label = isOverriding + ? `Using ${colorScheme} mode (press to follow system)` + : `Using system ${systemColorScheme} mode (press to switch)`; + let ref = useRef(null); + let isDark = colorScheme === 'dark'; + + return ( + + ); +} + export default function HomeHeader() { const [searchOpen, setSearchOpen] = useState(false); const searchMenuId = useId(); @@ -150,6 +224,8 @@ export default function HomeHeader() { Blog + + ); diff --git a/packages/dev/s2-docs/pages/s2/index.mdx b/packages/dev/s2-docs/pages/s2/index.mdx index cb971aa5b53..6c107d89651 100644 --- a/packages/dev/s2-docs/pages/s2/index.mdx +++ b/packages/dev/s2-docs/pages/s2/index.mdx @@ -3,6 +3,7 @@ import {Home} from './home/Home'; import {Provider} from '@react-spectrum/s2'; import rspFavicon from 'url:../../assets/rsp-favicon.svg'; import {RouterWrapperServer} from "@react-spectrum/s2-docs/src/SearchMenuWrapperServer"; +import {SettingsContextProvider} from '../../src/SettingsProvider'; import {getBaseUrl} from '../../src/pageUtils'; export const section = 'Overview'; @@ -49,6 +50,8 @@ export const hideFromSearch = true; } )}} /> - + + + diff --git a/packages/dev/s2-docs/src/Header.tsx b/packages/dev/s2-docs/src/Header.tsx index a0db85bfa0c..3f963d9b987 100644 --- a/packages/dev/s2-docs/src/Header.tsx +++ b/packages/dev/s2-docs/src/Header.tsx @@ -1,18 +1,20 @@ 'use client'; import {baseColor, focusRing, space, style} from '@react-spectrum/s2/style' with { type: 'macro' }; +import {Button, Link} from 'react-aria-components'; +import Contrast from '@react-spectrum/s2/icons/Contrast'; +import {Divider, pressScale} from '@react-spectrum/s2'; import {getBaseUrl} from './pageUtils'; import {getLibraryFromPage, getLibraryIcon, getLibraryLabel} from './library'; import GithubLogo from './icons/GithubLogo'; import {HeaderLink} from './Link'; -// @ts-ignore -import {Link} from 'react-aria-components'; +import Lighten from '@react-spectrum/s2/icons/Lighten'; import {NpmLogo} from './icons/NpmLogo'; -import {pressScale} from '@react-spectrum/s2'; import React, {useId, useRef, useState} from 'react'; import SearchMenuTrigger, {preloadSearchMenu} from './SearchMenuTrigger'; import {useLayoutEffect} from '@react-aria/utils'; import {useRouter} from './Router'; +import {useSettings} from './SettingsContext'; function getButtonText(currentPage) { return getLibraryLabel(getLibraryFromPage(currentPage)); @@ -44,6 +46,69 @@ const libraryStyles = style({ marginStart: space(26) }); +const colorSchemeToggleStyles = style({ + ...focusRing(), + font: 'ui', + color: 'neutral', + textDecoration: 'none', + transition: 'default', + backgroundColor: { + default: { + ...baseColor('gray-100'), + default: 'transparent' + } + }, + size: 32, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 'lg', + borderWidth: 0 +}); + +const iconContainerStyles = style({ + position: 'relative', + size: 20 +}); + +function ColorSchemeToggle() { + let {colorScheme, toggleColorScheme, systemColorScheme} = useSettings(); + let isOverriding = colorScheme !== systemColorScheme; + let label = isOverriding + ? `Using ${colorScheme} mode (press to use system)` + : `Using system ${systemColorScheme} mode (press to switch)`; + let ref = useRef(null); + let isDark = colorScheme === 'dark'; + + return ( + + ); +} + export default function Header() { const {currentPage} = useRouter(); const [searchOpen, setSearchOpen] = useState(false); @@ -166,6 +231,12 @@ export default function Header() { Blog + {library !== 'react-aria' && ( + <> + + + + )} diff --git a/packages/dev/s2-docs/src/Layout.tsx b/packages/dev/s2-docs/src/Layout.tsx index 7563993e38a..8813d07fc26 100644 --- a/packages/dev/s2-docs/src/Layout.tsx +++ b/packages/dev/s2-docs/src/Layout.tsx @@ -24,6 +24,7 @@ import {Link} from './Link'; import {Main, NavigationSuspense, Router} from './Router'; import {MobileHeader} from './MobileHeader'; import {PropTable} from './PropTable'; +import {SettingsProvider} from './SettingsProvider'; import {StateTable} from './StateTable'; import {style} from '@react-spectrum/s2/style' with {type: 'macro'}; import {TypeLink} from './types'; @@ -215,94 +216,96 @@ export async function Layout(props: PageProps & {children: ReactElement}) { lg: 'none' } })}> -
-
- } /> -
-
- - {!isToastPage && } + {!isToastPage && } + diff --git a/packages/dev/s2-docs/src/MobileHeader.tsx b/packages/dev/s2-docs/src/MobileHeader.tsx index f5b47de7230..9b7815cf00f 100644 --- a/packages/dev/s2-docs/src/MobileHeader.tsx +++ b/packages/dev/s2-docs/src/MobileHeader.tsx @@ -1,16 +1,19 @@ 'use client'; -import {ActionButton, DialogTrigger, pressScale} from '@react-spectrum/s2'; -import {focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {ActionButton, DialogTrigger, pressScale, Provider} from '@react-spectrum/s2'; +import {baseColor, focusRing, style} from '@react-spectrum/s2/style' with {type: 'macro'}; +import {Button, Link, Modal, ModalOverlay} from 'react-aria-components'; +import Contrast from '@react-spectrum/s2/icons/Contrast'; import {getBaseUrl} from './pageUtils'; import {getLibraryFromPage, getLibraryIcon} from './library'; import {keyframes} from '../../../@react-spectrum/s2/style/style-macro' with {type: 'macro'}; -import {Link, Modal, ModalOverlay} from 'react-aria-components'; +import Lighten from '@react-spectrum/s2/icons/Lighten'; import MenuHamburger from '@react-spectrum/s2/icons/MenuHamburger'; import React, {CSSProperties, lazy, useEffect, useRef, useState} from 'react'; import {TAB_DEFS} from './constants'; import {useLayoutEffect} from '@react-aria/utils'; import {useRouter} from './Router'; +import {useSettings} from './SettingsContext'; import './SearchMenu.css'; const MobileSearchMenu = lazy(() => import('./SearchMenu').then(({MobileSearchMenu}) => ({default: MobileSearchMenu}))); @@ -64,10 +67,74 @@ const animation = { const animationRange = '24px 64px'; +const colorSchemeToggleStyles = style({ + ...focusRing(), + font: 'ui', + color: 'neutral', + textDecoration: 'none', + transition: 'default', + backgroundColor: { + default: { + ...baseColor('gray-100'), + default: 'transparent' + } + }, + size: 32, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 'lg', + borderWidth: 0 +}); + +const iconContainerStyles = style({ + position: 'relative', + size: 20 +}); + +function ColorSchemeToggle() { + let {colorScheme, toggleColorScheme, systemColorScheme} = useSettings(); + let isOverriding = colorScheme !== systemColorScheme; + let label = isOverriding + ? `Using ${colorScheme} mode (press to use system)` + : `Using system ${systemColorScheme} mode (press to switch)`; + let ref = useRef(null); + let isDark = colorScheme === 'dark'; + + return ( + + ); +} + export function MobileHeader({toc}) { let ref = useRef(null); let linkRef = useRef(null); let labelRef = useRef(null); + let {colorScheme} = useSettings(); useEffect(() => { // Tiny polyfill for scroll driven animations. @@ -241,6 +308,7 @@ export function MobileHeader({toc}) { {toc} )} + {library !== 'react-aria' && } @@ -276,7 +344,9 @@ export function MobileHeader({toc}) { width: 'full', height: '--visual-viewport-height' })}> - + + + diff --git a/packages/dev/s2-docs/src/SearchMenuTrigger.tsx b/packages/dev/s2-docs/src/SearchMenuTrigger.tsx index 5b79c16b0d4..2051efcb829 100644 --- a/packages/dev/s2-docs/src/SearchMenuTrigger.tsx +++ b/packages/dev/s2-docs/src/SearchMenuTrigger.tsx @@ -3,10 +3,11 @@ import {Button, ButtonProps, Modal, ModalOverlay} from 'react-aria-components'; import {fontRelative, lightDark, style} from '@react-spectrum/s2/style' with { type: 'macro' }; import {getLibraryFromPage, getLibraryLabel} from './library'; +import {Provider, Button as S2Button, ButtonProps as S2ButtonProps} from '@react-spectrum/s2'; import React, {lazy, useCallback, useEffect, useRef, useState} from 'react'; -import {Button as S2Button, ButtonProps as S2ButtonProps} from '@react-spectrum/s2'; import Search from '@react-spectrum/s2/icons/Search'; import {useRouter} from './Router'; +import {useSettings} from './SettingsContext'; let SearchMenu = lazy(() => import('./SearchMenu').then(({SearchMenu}) => ({default: SearchMenu}))); export async function preloadSearchMenu() { @@ -55,6 +56,7 @@ let modalStyle = style({ export default function SearchMenuTrigger({onOpen, onClose, isSearchOpen, overlayId, staticColor, ...props}: SearchMenuTriggerProps) { let {currentPage} = useRouter(); + let {colorScheme} = useSettings(); let [initialSearchValue, setInitialSearchValue] = useState(''); let open = useCallback((value: string) => { setInitialSearchValue(value); @@ -260,18 +262,20 @@ export default function SearchMenuTrigger({onOpen, onClose, isSearchOpen, overla // @ts-ignore viewTransitionName: 'search-menu-underlay' }}> - - - + + + + + ); diff --git a/packages/dev/s2-docs/src/SearchMenuWrapper.tsx b/packages/dev/s2-docs/src/SearchMenuWrapper.tsx index 879d3dec337..9dd91c52098 100644 --- a/packages/dev/s2-docs/src/SearchMenuWrapper.tsx +++ b/packages/dev/s2-docs/src/SearchMenuWrapper.tsx @@ -2,13 +2,16 @@ import {Button, ButtonContext, ButtonProps, DialogTrigger} from 'react-aria-components'; import {preloadSearchMenu} from './SearchMenuTrigger'; +import {Provider} from '@react-spectrum/s2'; import React, {lazy, ReactNode} from 'react'; import {Modal as S2Modal} from '../../../@react-spectrum/s2/src/Modal'; import {style} from '@react-spectrum/s2/style' with { type: 'macro' }; +import {useSettings} from './SettingsContext'; const MobileSearchMenu = lazy(() => import('./SearchMenu').then(({MobileSearchMenu}) => ({default: MobileSearchMenu}))); export default function SearchMenuWrapper({children}: {children: ReactNode}) { + let {colorScheme} = useSettings(); return ( <>
@@ -20,7 +23,9 @@ export default function SearchMenuWrapper({children}: {children: ReactNode}) { {children} - + + +
diff --git a/packages/dev/s2-docs/src/SettingsContext.tsx b/packages/dev/s2-docs/src/SettingsContext.tsx new file mode 100644 index 00000000000..f9a28cdcc64 --- /dev/null +++ b/packages/dev/s2-docs/src/SettingsContext.tsx @@ -0,0 +1,21 @@ +'use client'; + +import {createContext, useContext} from 'react'; + +export type ColorScheme = 'light' | 'dark'; + +interface SettingsContextValue { + colorScheme: ColorScheme, + toggleColorScheme: () => void, + systemColorScheme: ColorScheme +} + +export const SettingsContext = createContext({ + colorScheme: 'light', + toggleColorScheme: () => {}, + systemColorScheme: 'light' +}); + +export function useSettings() { + return useContext(SettingsContext); +} diff --git a/packages/dev/s2-docs/src/SettingsProvider.tsx b/packages/dev/s2-docs/src/SettingsProvider.tsx new file mode 100644 index 00000000000..2eaf68b6219 --- /dev/null +++ b/packages/dev/s2-docs/src/SettingsProvider.tsx @@ -0,0 +1,88 @@ +'use client'; + +import {type ColorScheme, SettingsContext} from './SettingsContext'; +import {Provider} from '@react-spectrum/s2'; +import React, {ReactNode, useCallback, useSyncExternalStore} from 'react'; +import {useLocalStorage} from './useLocalStorage'; + +interface SettingsProviderProps { + children: ReactNode +} + +function subscribeToColorScheme(callback: () => void) { + let mq = window.matchMedia('(prefers-color-scheme: dark)'); + mq.addEventListener('change', callback); + return () => mq.removeEventListener('change', callback); +} + +function getSystemColorScheme(): ColorScheme { + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function getServerSnapshot(): ColorScheme { + return 'light'; +} + +export function useSettingsState() { + // Store the "override" color scheme ('light', 'dark', or 'system') + let [storedColorScheme, setStoredColorScheme] = useLocalStorage('colorScheme', 'system'); + let systemColorScheme = useSyncExternalStore(subscribeToColorScheme, getSystemColorScheme, getServerSnapshot); + + // Resolve the actual color scheme being used + let colorScheme: ColorScheme = storedColorScheme === 'system' ? systemColorScheme : storedColorScheme as ColorScheme; + + // Toggle between system preference and the "other" one + let toggleColorScheme = useCallback(() => { + if (storedColorScheme === 'system') { + // Currently following system, switch to the opposite of system preference + setStoredColorScheme(systemColorScheme === 'dark' ? 'light' : 'dark'); + } else { + // Currently overriding, go back to system + setStoredColorScheme('system'); + } + }, [storedColorScheme, systemColorScheme, setStoredColorScheme]); + + + let providerColorScheme: ColorScheme | undefined = storedColorScheme === 'system' ? undefined : storedColorScheme as ColorScheme; + + return { + colorScheme, + toggleColorScheme, + systemColorScheme, + providerColorScheme + }; +} + +export function SettingsContextProvider({children}: SettingsProviderProps) { + let {colorScheme, toggleColorScheme, systemColorScheme, providerColorScheme} = useSettingsState(); + + return ( + + + {children} + + + ); +} + +export function SettingsProvider({children}: SettingsProviderProps) { + let {colorScheme, toggleColorScheme, systemColorScheme, providerColorScheme} = useSettingsState(); + + return ( + + + {children} + + + ); +}