diff --git a/src/ColorProvider/index.tsx b/src/ColorProvider/index.tsx index 36e57a0..1e8aebc 100644 --- a/src/ColorProvider/index.tsx +++ b/src/ColorProvider/index.tsx @@ -1,168 +1,144 @@ import React, { - useEffect, - useMemo, - useState, - type PropsWithChildren, + type PropsWithChildren, + useEffect, + useInsertionEffect, + useLayoutEffect, + useMemo, + useState, } from "react"; import ColorContext, { - type ColorContextValue, - type ColorScheme, - type ColorSchemePreference, + type ColorContextValue, + type ColorScheme, + type ColorSchemePreference, } from "../ColorContext"; import useSystemColorScheme from "../hooks/useSystemColorScheme"; import { applyTheme } from "../theme"; -// License notes: a lot of the code having to do with runtime reactive switching came from GitHub's MIT code: -// https://github.com/primer/react/blob/e1268ff35acf48561adef9e55f8add39f69924eb/packages/react/src/ThemeProvider.tsx#L146 +const STORAGE_KEY = + "@determinate-systems/ui/ColorProvider/scheme-preference"; export interface ColorProviderProps { - /** Root element for this color context. Defaults to the HTML `` element, but can be scoped more narrowly for testing. */ - root?: Element; + /** Root element for this color context. Defaults to the HTML `` element, but can be scoped more narrowly for testing. */ + root?: Element | undefined; - /** - * Sync the user's preferred color scheme to local storage. - * - * Defaults to enabled, specify false to turn it off. - **/ - useLocalStorage?: boolean; + /** + * Sync the user's preferred color scheme to local storage. + * + * Defaults to enabled, specify false to turn it off. + **/ + useLocalStorage?: boolean; - /** (For testing) Which color scheme to use instead of querying the system? */ - simulatedSystemColorScheme?: ColorScheme; + /** (For testing) Which color scheme to use instead of querying the system? */ + simulatedSystemColorScheme?: ColorScheme; - /** (For testing) Which color scheme does the user prefer? */ - preferredColorScheme?: ColorSchemePreference; + /** (For testing) Which color scheme does the user prefer? */ + preferredColorScheme?: ColorSchemePreference; } function computeInitialColorSchemePreference( - useLocalStorage: boolean, - preferredColorScheme?: ColorSchemePreference, + useLocalStorage: boolean, + preferredColorScheme?: ColorSchemePreference, ): ColorSchemePreference { - if (useLocalStorage) { - const storedPreference = readSchemeFromLocalStorage(); - if (storedPreference) { - return storedPreference; - } - } - - if (preferredColorScheme) { - return preferredColorScheme; - } - - // In case we're doing server-side rendering, just render `dark` be done with it. - if (typeof window === "undefined") { - return "dark"; - } - - return "auto"; + if (useLocalStorage) { + const storedPreference = readSchemeFromLocalStorage(); + if (storedPreference) return storedPreference; + } + + if (preferredColorScheme) return preferredColorScheme; + + // SPA default + return "auto"; } function readSchemeFromLocalStorage(): ColorSchemePreference | undefined { - let pref; - - try { - pref = localStorage.getItem( - "@determinate-systems/ui/ColorProvider/scheme-preference", - ); - } catch { - return undefined; - } - - switch (pref) { - case "auto": - case "light": - case "dark": - return pref; - default: - return undefined; - } + const value = localStorage.getItem(STORAGE_KEY); + return value === "auto" || value === "light" || value === "dark" + ? value + : undefined; } function writeSchemeToLocalStorage(preference: ColorSchemePreference) { - try { - localStorage.setItem( - "@determinate-systems/ui/ColorProvider/scheme-preference", - preference, - ); - } catch { - // Ignore errors - } + try { + localStorage.setItem( + "@determinate-systems/ui/ColorProvider/scheme-preference", + preference, + ); + } catch { + // Ignore errors + } } -// Helper component for -const ColorProvider: React.FC> = ({ - useLocalStorage = true, - simulatedSystemColorScheme, - preferredColorScheme, - root = document.body, - children, -}) => { - const actualSystemColorScheme = useSystemColorScheme(); - const systemColorScheme = - simulatedSystemColorScheme ?? actualSystemColorScheme; - - const [preference, setPreference] = useState(() => - computeInitialColorSchemePreference(useLocalStorage, preferredColorScheme), - ); - const [scheme, setScheme] = useState(() => - resolveColorScheme(preference, systemColorScheme), - ); - - // Apply the theme super early so we don't get a FOUC - applyTheme(root, scheme); - - // Since we're pretty high up in the component tree, we want to be extremely - // careful about re-rendering. Memoization ensures that the object only - // changes when the scheme or preference does. - const value = useMemo( - () => ({ scheme, setScheme, preference, setPreference }), - [scheme, preference], - ); - - // Switch body classes depending on the chosen scheme - useEffect(() => { - writeSchemeToLocalStorage(preference); - setScheme(resolveColorScheme(preference, systemColorScheme)); - }, [preference, systemColorScheme, setScheme]); - - // Switch body classes depending on the chosen scheme - useEffect(() => { - applyTheme(root, scheme); - }, [scheme]); - - return ( - {children} - ); -}; - function resolveColorScheme( - preferredScheme: ColorSchemePreference, - systemColorScheme?: ColorScheme, + preferredScheme: ColorSchemePreference, + systemColorScheme?: ColorScheme, ): ColorScheme { - switch (preferredScheme) { - case "auto": - if (systemColorScheme) { - return systemColorScheme; - } - return "dark"; - default: - return preferredScheme; - } + if (preferredScheme === "auto") return systemColorScheme ?? "dark"; + return preferredScheme; } -const nextSchemePreference = ( - preference: ColorSchemePreference, +// Apply as early as React allows (insertion), else layout. +const useEarlyEffect = + typeof useInsertionEffect === "function" + ? useInsertionEffect + : useLayoutEffect; + +const ColorProvider: React.FC> = ({ + useLocalStorage = true, + simulatedSystemColorScheme, + preferredColorScheme, + root: initialRoot = undefined, + children, +}) => { + const actualSystemColorScheme = useSystemColorScheme(); + const systemColorScheme = + simulatedSystemColorScheme ?? actualSystemColorScheme; + + const root: Element | null = + initialRoot ?? (typeof document !== "undefined" ? document.body : null); + + const [preference, setPreference] = useState(() => + computeInitialColorSchemePreference(useLocalStorage, preferredColorScheme), + ); + + const [scheme, setScheme] = useState(() => + resolveColorScheme(preference, systemColorScheme), + ); + + // Sync scheme whenever preference/system changes + useEffect(() => { + if (useLocalStorage) writeSchemeToLocalStorage(preference); + setScheme(resolveColorScheme(preference, systemColorScheme)); + }, [preference, systemColorScheme, useLocalStorage]); + + // Apply the theme "super early" (without doing it during render) + useEarlyEffect(() => { + if (!root) return; + applyTheme(root, scheme); + }, [root, scheme]); + + const value = useMemo( + () => ({ scheme, setScheme, preference, setPreference }), + [scheme, preference], + ); + + return ( + {children} + ); +}; + +export const nextSchemePreference = ( + preference: ColorSchemePreference, ): ColorSchemePreference => { - switch (preference) { - case "auto": - return "dark"; - case "dark": - return "light"; - case "light": - return "auto"; - } + switch (preference) { + case "auto": + return "dark"; + case "dark": + return "light"; + case "light": + return "auto"; + } }; -export { nextSchemePreference }; export default ColorProvider; diff --git a/src/ColorSchemeToggle/index.tsx b/src/ColorSchemeToggle/index.tsx index 18e9cb3..3923bed 100644 --- a/src/ColorSchemeToggle/index.tsx +++ b/src/ColorSchemeToggle/index.tsx @@ -1,47 +1,44 @@ -import { useContext, type FC } from "react"; +import { type FC, useContext, useMemo } from "react"; +import { IoMoonSharp, IoSunnySharp } from "react-icons/io5"; import ColorContext, { type ColorSchemePreference } from "../ColorContext"; import { nextSchemePreference } from "../ColorProvider"; -import { IoMoonSharp, IoSunnySharp } from "react-icons/io5"; import AutoIcon from "./AutoIcon"; import "./index.scss"; const icons = { - light: , - dark: , - auto: , + light: , + dark: , + auto: , }; -export interface ColorSchemeToggleProps {} +const ColorSchemeToggle: FC = () => { + const ctx = useContext(ColorContext); -const ColorSchemeToggle: FC = ({}) => { - const colorSchemeCtx = useContext(ColorContext); + const icon = useMemo(() => icons[ctx.preference], [ctx.preference]); - return ( - - ); + return ( + + ); }; export interface IconProps { - preference: ColorSchemePreference; + preference: ColorSchemePreference; } -const Icon: FC = ({ preference }) => { - return icons[preference]; -}; - export default ColorSchemeToggle; diff --git a/src/index.ts b/src/index.ts index 5821ba8..566e50f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,87 +2,65 @@ import "./sass/preflight.scss"; import "./sass/reset.scss"; -export { applyTheme, themeForScheme } from "./theme"; - -// Hooks -export { - type HighlightLanguage, - default as useHighlight, -} from "./hooks/useHighlight"; -export { default as useObjectURL } from "./hooks/useObjectURL"; - // Components export { - type BusyIconProps, - type BusyIconSize, - default as BusyIcon, + type BusyIconProps, + type BusyIconSize, + default as BusyIcon, } from "./BusyIcon"; - export { type ButtonProps, default as Button } from "./Button"; - export { type CodeBlockProps, default as CodeBlock } from "./CodeBlock"; - export { type CodeFileProps, default as CodeFile } from "./CodeFile"; - export { type CodeInlineProps, default as CodeInline } from "./CodeInline"; - export { - type ColorContextValue, - type ColorScheme, - default as ColorContext, + type ColorContextValue, + type ColorScheme, + default as ColorContext, } from "./ColorContext"; - -export { - type ColorSchemeToggleProps, - default as ColorSchemeToggle, -} from "./ColorSchemeToggle"; - export { - type ColorProviderProps, - default as ColorProvider, + type ColorProviderProps, + default as ColorProvider, } from "./ColorProvider"; - +export { + default as ColorSchemeToggle, +} from "./ColorSchemeToggle"; export { type CopyButtonProps, default as CopyButton } from "./CopyButton"; - export { type DetSysIconProps, default as DetSysIcon } from "./DetSysIcon"; - export { - type DownloadButtonProps, - default as DownloadButton, + type DownloadButtonProps, + default as DownloadButton, } from "./DownloadButton"; -export { type HighlightProps, default as Highlight } from "./Highlight"; - -export { - type CTAType, - type InstallCTAProps, - default as InstallCTA, -} from "./InstallCTA"; - -export { type LinkProps, default as Link } from "./Link"; - -export { default as MacInstaller } from "./MacInstaller"; - export { - type GitHubButtonProps, - default as GitHubButton, + default as GitHubButton, + type GitHubButtonProps, } from "./GitHubButton"; - -export { type HeaderProps, default as Header } from "./Header"; - +export { default as Header, type HeaderProps } from "./Header"; +export { default as Highlight, type HighlightProps } from "./Highlight"; +// Hooks export { - type LabeledInputProps, - type LabeledInputRenderProps, - default as LabeledInput, + default as useHighlight, + type HighlightLanguage, +} from "./hooks/useHighlight"; +export { default as useObjectURL } from "./hooks/useObjectURL"; +export { + type CTAType, + default as InstallCTA, + type InstallCTAProps, +} from "./InstallCTA"; +export { + default as LabeledInput, + type LabeledInputProps, + type LabeledInputRenderProps, } from "./LabeledInput"; - export { - type LabeledRadioInputProps, - default as LabeledRadioInput, + default as LabeledRadioInput, + type LabeledRadioInputProps, } from "./LabeledRadioInput"; - export { - type LabeledTextInputProps, - default as LabeledTextInput, + default as LabeledTextInput, + type LabeledTextInputProps, } from "./LabeledTextInput"; - -export { type PageLayoutProps, default as PageLayout } from "./PageLayout"; +export { default as Link, type LinkProps } from "./Link"; +export { default as MacInstaller } from "./MacInstaller"; +export { default as PageLayout, type PageLayoutProps } from "./PageLayout"; +export { applyTheme, themeForScheme } from "./theme";