Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
244 changes: 110 additions & 134 deletions src/ColorProvider/index.tsx
Original file line number Diff line number Diff line change
@@ -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 `<body>` element, but can be scoped more narrowly for testing. */
root?: Element;
/** Root element for this color context. Defaults to the HTML `<body>` 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<PropsWithChildren<ColorProviderProps>> = ({
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<ColorContextValue>(
() => ({ 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 (
<ColorContext.Provider value={value}>{children}</ColorContext.Provider>
);
};

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<PropsWithChildren<ColorProviderProps>> = ({
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<ColorSchemePreference>(() =>
computeInitialColorSchemePreference(useLocalStorage, preferredColorScheme),
);

const [scheme, setScheme] = useState<ColorScheme>(() =>
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<ColorContextValue>(
() => ({ scheme, setScheme, preference, setPreference }),
[scheme, preference],
);

return (
<ColorContext.Provider value={value}>{children}</ColorContext.Provider>
);
};

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;
59 changes: 28 additions & 31 deletions src/ColorSchemeToggle/index.tsx
Original file line number Diff line number Diff line change
@@ -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: <IoSunnySharp aria-label="Light mode" height="100%" width="100%" />,
dark: <IoMoonSharp aria-label="Dark mode" height="100%" width="100%" />,
auto: <AutoIcon height="100%" width="100%" />,
light: <IoSunnySharp aria-label="Light mode" height="100%" width="100%" />,
dark: <IoMoonSharp aria-label="Dark mode" height="100%" width="100%" />,
auto: <AutoIcon height="100%" width="100%" />,
};

export interface ColorSchemeToggleProps {}
const ColorSchemeToggle: FC = () => {
const ctx = useContext(ColorContext);

const ColorSchemeToggle: FC<ColorSchemeToggleProps> = ({}) => {
const colorSchemeCtx = useContext(ColorContext);
const icon = useMemo(() => icons[ctx.preference], [ctx.preference]);

return (
<button
className="color-scheme-toggle"
aria-label="Toggle light/dark/auto mode"
onClick={() => {
colorSchemeCtx.setPreference(
nextSchemePreference(colorSchemeCtx.preference),
);
}}
>
<div
data-testid="color-scheme__icon"
className={`color-scheme-toggle__icon color-scheme-toggle__icon--${colorSchemeCtx.scheme}`}
>
<Icon preference={colorSchemeCtx.preference} />
</div>
</button>
);
return (
<button
type="button"
className="color-scheme-toggle"
aria-label="Toggle light/dark/auto mode"
onClick={() => {
ctx.setPreference(
nextSchemePreference(ctx.preference),
);
}}
>
<div
data-testid="color-scheme__icon"
className={`color-scheme-toggle__icon color-scheme-toggle__icon--${ctx.scheme}`}
>
{icon}
</div>
</button>
);
};

export interface IconProps {
preference: ColorSchemePreference;
preference: ColorSchemePreference;
}

const Icon: FC<IconProps> = ({ preference }) => {
return icons[preference];
};

export default ColorSchemeToggle;
Loading
Loading