diff --git a/components/Captcha/Captcha.tsx b/components/Captcha/Captcha.tsx index f0c1e699..4e7afd3c 100644 --- a/components/Captcha/Captcha.tsx +++ b/components/Captcha/Captcha.tsx @@ -1,7 +1,7 @@ import { memo } from "react"; import Head from "next/head"; -import { useTheme } from "next-themes"; import HCaptcha from "@hcaptcha/react-hcaptcha"; +import { useTheme } from "../../hooks/use-theme"; export type CaptchaProps = { size?: "normal" | "compact" | "invisible"; diff --git a/components/Comments/Comments.tsx b/components/Comments/Comments.tsx index e2019f42..a89ad100 100644 --- a/components/Comments/Comments.tsx +++ b/components/Comments/Comments.tsx @@ -1,6 +1,6 @@ import { memo } from "react"; -import { useTheme } from "next-themes"; import { Giscus } from "@giscus/react"; +import { useTheme } from "../../hooks/use-theme"; import { styled } from "../../lib/styles/stitches.config"; import { giscusConfig } from "../../lib/config"; import type { ComponentProps } from "react"; diff --git a/components/Layout/Layout.tsx b/components/Layout/Layout.tsx index c902103f..7afb69fa 100644 --- a/components/Layout/Layout.tsx +++ b/components/Layout/Layout.tsx @@ -1,7 +1,7 @@ import Head from "next/head"; -import { useTheme } from "next-themes"; import Header from "../Header/Header"; import Footer from "../Footer/Footer"; +import { useTheme } from "../../hooks/use-theme"; import { styled, theme, darkTheme } from "../../lib/styles/stitches.config"; import type { ComponentProps } from "react"; diff --git a/components/ThemeToggle/ThemeToggle.tsx b/components/ThemeToggle/ThemeToggle.tsx index ce56074d..738a3c7b 100644 --- a/components/ThemeToggle/ThemeToggle.tsx +++ b/components/ThemeToggle/ThemeToggle.tsx @@ -1,5 +1,5 @@ import { useEffect, useState, memo } from "react"; -import { useTheme } from "next-themes"; +import { useTheme } from "../../hooks/use-theme"; import { styled } from "../../lib/styles/stitches.config"; import { SunIcon, MoonIcon } from "../Icons"; diff --git a/components/TweetEmbed/TweetEmbed.tsx b/components/TweetEmbed/TweetEmbed.tsx index 91811990..6ffcc9c3 100644 --- a/components/TweetEmbed/TweetEmbed.tsx +++ b/components/TweetEmbed/TweetEmbed.tsx @@ -1,5 +1,5 @@ -import { useTheme } from "next-themes"; import { TwitterTweetEmbed } from "react-twitter-embed"; +import { useTheme } from "../../hooks/use-theme"; export type TweetEmbedProps = { id: string; diff --git a/hooks/use-theme.tsx b/hooks/use-theme.tsx new file mode 100644 index 00000000..6af52fa5 --- /dev/null +++ b/hooks/use-theme.tsx @@ -0,0 +1,286 @@ +// forked & modified from pacocoursey/next-themes as of: +// https://github.com/pacocoursey/next-themes/tree/b5c2bad50de2d61ad7b52a9c5cdc801a78507d7a + +import { createContext, useCallback, useContext, useEffect, useState, useRef, memo } from "react"; +import NextHead from "next/head"; +import type { PropsWithChildren } from "react"; + +interface UseThemeProps { + /** List of all available theme names */ + themes: string[]; + /** Active theme name */ + theme?: string; + /** If the active theme is "system", this returns whether the system preference resolved to "dark" or "light". Otherwise, identical to `theme` */ + resolvedTheme?: string; + /** Forced theme name for the current page */ + forcedTheme?: string; + /** Update the theme */ + setTheme: (theme: string) => void; +} + +export interface ThemeProviderProps { + /** List of all available theme names */ + themes?: string[]; + /** Forced theme name for the current page */ + forcedTheme?: string; + /** Whether to indicate to browsers which color scheme is used (dark or light) for built-in UI like inputs and buttons */ + enableColorScheme?: boolean; + /** Key used to store theme setting in localStorage */ + storageKey?: string; + /** Default theme name (for v0.0.12 and lower the default was light). If `enableSystem` is false, the default theme is light */ + defaultTheme?: string; + /** HTML attribute modified based on the active theme. Accepts `class` and `data-*` (meaning any data attribute, `data-mode`, `data-color`, etc.) */ + attribute?: string | "class"; + /** Mapping of theme name to HTML attribute value. Object where key is the theme name and value is the attribute value */ + value?: ValueObject; +} + +const ThemeContext = createContext({ + // eslint-disable-next-line @typescript-eslint/no-empty-function, @typescript-eslint/no-unused-vars + setTheme: (_) => {}, + themes: [], +}); +export const useTheme = () => useContext(ThemeContext); + +const colorSchemes = ["light", "dark"]; +const MEDIA = "(prefers-color-scheme: dark)"; + +interface ValueObject { + [themeName: string]: string; +} + +export const ThemeProvider = ({ + forcedTheme, + enableColorScheme = true, + storageKey = "theme", + themes = [...colorSchemes], + defaultTheme = "system", + attribute = "data-theme", + value, + children, +}: PropsWithChildren) => { + const [theme, setThemeState] = useState(() => getTheme(storageKey, defaultTheme)); + const [resolvedTheme, setResolvedTheme] = useState(() => getTheme(storageKey)); + const attrs = !value ? themes : Object.values(value); + + const handleMediaQuery = useCallback( + (e?) => { + const systemTheme = getSystemTheme(e); + setResolvedTheme(systemTheme); + if (theme === "system" && !forcedTheme) changeTheme(systemTheme, false); + }, + [theme, forcedTheme] // eslint-disable-line react-hooks/exhaustive-deps + ); + + // Ref hack to avoid adding handleMediaQuery as a dep + const mediaListener = useRef(handleMediaQuery); + mediaListener.current = handleMediaQuery; + + const changeTheme = useCallback((theme, updateStorage = true, updateDOM = true) => { + let name = value?.[theme] || theme; + + if (updateStorage) { + try { + localStorage.setItem(storageKey, theme); + } catch (e) { + // Unsupported + } + } + + if (theme === "system") { + const resolved = getSystemTheme(); + name = value?.[resolved] || resolved; + } + + if (updateDOM) { + const d = document.documentElement; + + if (attribute === "class") { + d.classList.remove(...attrs); + d.classList.add(name); + } else { + d.setAttribute(attribute, name); + } + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + useEffect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handler = (...args: any) => mediaListener.current(...args); + + // Always listen to System preference + const media = window.matchMedia(MEDIA); + + // Intentionally use deprecated listener methods to support iOS & old browsers + media.addListener(handler); + handler(media); + + return () => media.removeListener(handler); + }, []); + + const setTheme = useCallback( + (newTheme) => { + if (forcedTheme) { + changeTheme(newTheme, true, false); + } else { + changeTheme(newTheme); + } + setThemeState(newTheme); + }, + [forcedTheme] // eslint-disable-line react-hooks/exhaustive-deps + ); + + // localStorage event handling + useEffect(() => { + const handleStorage = (e: StorageEvent) => { + if (e.key !== storageKey) { + return; + } + // If default theme set, use it if localstorage === null (happens on local storage manual deletion) + const theme = e.newValue || defaultTheme; + setTheme(theme); + }; + + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, [setTheme]); // eslint-disable-line react-hooks/exhaustive-deps + + // color-scheme handling + useEffect(() => { + if (!enableColorScheme) return; + + const colorScheme = + // If theme is forced to light or dark, use that + forcedTheme && colorSchemes.includes(forcedTheme) + ? forcedTheme + : // If regular theme is light or dark + theme && colorSchemes.includes(theme) + ? theme + : // If theme is system, use the resolved version + theme === "system" + ? resolvedTheme || null + : null; + + // color-scheme tells browser how to render built-in elements like forms, scrollbars, etc. + // if color-scheme is null, this will remove the property + document.documentElement.style.setProperty("color-scheme", colorScheme); + }, [enableColorScheme, theme, resolvedTheme, forcedTheme]); + + return ( + + + {children} + + ); +}; + +const ThemeScript = memo( + function ThemeScript({ + forcedTheme, + storageKey, + attribute, + defaultTheme, + value, + attrs, + }: { + forcedTheme?: string; + storageKey: string; + attribute?: string; + defaultTheme: string; + value?: ValueObject; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + attrs: any; + }) { + // Code-golfing the amount of characters in the script + const optimization = (() => { + if (attribute === "class") { + const removeClasses = `d.remove(${attrs.map((t: string) => `"${t}"`).join(",")})`; + + return `var d=document.documentElement.classList;${removeClasses};`; + } else { + return `var d=document.documentElement;`; + } + })(); + + const updateDOM = (name: string, literal?: boolean) => { + name = value?.[name] || name; + const val = literal ? name : `"${name}"`; + + if (attribute === "class") { + return `d.add(${val})`; + } + + return `d.setAttribute("${attribute}", ${val})`; + }; + + const defaultSystem = defaultTheme === "system"; + + return ( + + {forcedTheme ? ( +