diff --git a/examples/app-router/CHANGELOG.md b/examples/app-router/CHANGELOG.md index 62365f61..c9e95930 100644 --- a/examples/app-router/CHANGELOG.md +++ b/examples/app-router/CHANGELOG.md @@ -1,5 +1,13 @@ # app-router +## 0.0.22 + +### Patch Changes + +- Updated dependencies + - nextjs-themes@3.1.2 + - shared-ui@1.0.2 + ## 0.0.21 ### Patch Changes diff --git a/examples/app-router/app/layout.tsx b/examples/app-router/app/layout.tsx index 1356526c..01f07d4b 100644 --- a/examples/app-router/app/layout.tsx +++ b/examples/app-router/app/layout.tsx @@ -3,6 +3,7 @@ import { NextJsSSGThemeSwitcher } from "nextjs-themes/server"; import { Inter } from "next/font/google"; import { SharedRootLayout, darkThemes, lightThemes } from "shared-ui"; import Link from "next/link"; +import "nextjs-themes/src/styles.css"; const inter = Inter({ subsets: ["latin"] }); const forcedPages: ForcedPage[] = [ diff --git a/examples/app-router/package.json b/examples/app-router/package.json index 89e7b85c..c611f040 100644 --- a/examples/app-router/package.json +++ b/examples/app-router/package.json @@ -1,6 +1,6 @@ { "name": "app-router", - "version": "0.0.21", + "version": "0.0.22", "private": true, "scripts": { "dev": "next dev --port 3002", diff --git a/examples/pages-router/CHANGELOG.md b/examples/pages-router/CHANGELOG.md index 75532100..d10ace8a 100644 --- a/examples/pages-router/CHANGELOG.md +++ b/examples/pages-router/CHANGELOG.md @@ -1,5 +1,13 @@ # nextjs-pages-router +## 1.0.17 + +### Patch Changes + +- Updated dependencies + - nextjs-themes@3.1.2 + - shared-ui@1.0.2 + ## 1.0.16 ### Patch Changes diff --git a/examples/pages-router/package.json b/examples/pages-router/package.json index c769ff35..fb888bdf 100644 --- a/examples/pages-router/package.json +++ b/examples/pages-router/package.json @@ -1,6 +1,6 @@ { "name": "pages-router", - "version": "1.0.16", + "version": "1.0.17", "private": true, "scripts": { "dev": "next dev --port 3003", diff --git a/examples/simple-multi-theme/CHANGELOG.md b/examples/simple-multi-theme/CHANGELOG.md index 89ff543c..058f1201 100644 --- a/examples/simple-multi-theme/CHANGELOG.md +++ b/examples/simple-multi-theme/CHANGELOG.md @@ -1,5 +1,13 @@ # simple-multi-theme +## 1.0.17 + +### Patch Changes + +- Updated dependencies + - nextjs-themes@3.1.2 + - shared-ui@1.0.2 + ## 1.0.16 ### Patch Changes diff --git a/examples/simple-multi-theme/package.json b/examples/simple-multi-theme/package.json index 8dbd30c1..46e79c85 100644 --- a/examples/simple-multi-theme/package.json +++ b/examples/simple-multi-theme/package.json @@ -1,6 +1,6 @@ { "name": "simple-multi-theme", - "version": "1.0.16", + "version": "1.0.17", "private": true, "scripts": { "dev": "next dev --port 3001", diff --git a/examples/tailwind/CHANGELOG.md b/examples/tailwind/CHANGELOG.md index 59a368cf..790dacc6 100644 --- a/examples/tailwind/CHANGELOG.md +++ b/examples/tailwind/CHANGELOG.md @@ -1,5 +1,12 @@ # tailwind +## 0.1.11 + +### Patch Changes + +- Updated dependencies + - nextjs-themes@3.1.2 + ## 0.1.10 ### Patch Changes diff --git a/examples/tailwind/package.json b/examples/tailwind/package.json index 0268c313..9c8c3886 100644 --- a/examples/tailwind/package.json +++ b/examples/tailwind/package.json @@ -1,6 +1,6 @@ { "name": "tailwind", - "version": "0.1.10", + "version": "0.1.11", "private": true, "scripts": { "dev": "next dev", diff --git a/examples/vite/CHANGELOG.md b/examples/vite/CHANGELOG.md index a84ef6b3..b3847fa4 100644 --- a/examples/vite/CHANGELOG.md +++ b/examples/vite/CHANGELOG.md @@ -1,5 +1,13 @@ # vite-example +## 0.0.22 + +### Patch Changes + +- Updated dependencies + - nextjs-themes@3.1.2 + - shared-ui@1.0.2 + ## 0.0.21 ### Patch Changes diff --git a/examples/vite/package.json b/examples/vite/package.json index a0b62de4..036afe28 100644 --- a/examples/vite/package.json +++ b/examples/vite/package.json @@ -1,7 +1,7 @@ { "name": "vite-example", "private": true, - "version": "0.0.21", + "version": "0.0.22", "type": "module", "scripts": { "dev": "vite --port 3001", diff --git a/lib/nextjs-themes/CHANGELOG.md b/lib/nextjs-themes/CHANGELOG.md index b3227426..63571200 100644 --- a/lib/nextjs-themes/CHANGELOG.md +++ b/lib/nextjs-themes/CHANGELOG.md @@ -1,5 +1,11 @@ # nextjs-themes +## 3.1.2 + +### Patch Changes + +- Fix FOUC + ## 3.1.1 ### Patch Changes diff --git a/lib/nextjs-themes/package.json b/lib/nextjs-themes/package.json index 9590779e..606f272d 100644 --- a/lib/nextjs-themes/package.json +++ b/lib/nextjs-themes/package.json @@ -2,7 +2,7 @@ "name": "nextjs-themes", "author": "Mayank Kumar Chaudhari ", "private": false, - "version": "3.1.1", + "version": "3.1.2", "description": "Unleash the Power of React Server Components! Use multiple themes on your site with confidence, without losing any advantages of React Server Components.", "main": "./index.ts", "types": "./index.ts", diff --git a/lib/nextjs-themes/src/client/color-switch/color-switch.test.tsx b/lib/nextjs-themes/src/client/color-switch/color-switch.test.tsx index e7140ab3..3b238e92 100644 --- a/lib/nextjs-themes/src/client/color-switch/color-switch.test.tsx +++ b/lib/nextjs-themes/src/client/color-switch/color-switch.test.tsx @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, renderHook, screen } from "@testing-li import { ColorSwitch } from "./color-switch"; import { useTheme } from "../../hooks"; import { afterEach, describe, test } from "vitest"; +import { DARK, LIGHT, SYSTEM } from "../../constants"; describe("color-switch", () => { afterEach(cleanup); @@ -12,12 +13,12 @@ describe("color-switch", () => { render(); const element = screen.getByTestId("color-switch"); act(() => fireEvent.click(element)); - expect(hook.result.current.colorSchemePref).toBe("dark"); + expect(hook.result.current.colorSchemePref).toBe(DARK); act(() => fireEvent.click(element)); - expect(hook.result.current.colorSchemePref).toBe("light"); + expect(hook.result.current.colorSchemePref).toBe(LIGHT); act(() => fireEvent.click(element)); - expect(hook.result.current.colorSchemePref).toBe("system"); + expect(hook.result.current.colorSchemePref).toBe(SYSTEM); act(() => fireEvent.click(element)); - expect(hook.result.current.colorSchemePref).toBe("dark"); + expect(hook.result.current.colorSchemePref).toBe(DARK); }); }); diff --git a/lib/nextjs-themes/src/client/color-switch/color-switch.tsx b/lib/nextjs-themes/src/client/color-switch/color-switch.tsx index 9471bf4b..7619ea7e 100644 --- a/lib/nextjs-themes/src/client/color-switch/color-switch.tsx +++ b/lib/nextjs-themes/src/client/color-switch/color-switch.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { useTheme } from "../../hooks"; +import { DARK, LIGHT, SYSTEM } from "../../constants"; export interface ColorSwitchProps { /** Diameter of the color switch */ @@ -22,19 +23,19 @@ export interface ColorSwitchProps { * * ``` */ -export function ColorSwitch({ size = 25, skipSystem }: ColorSwitchProps) { +export const ColorSwitch = ({ size = 25, skipSystem }: ColorSwitchProps) => { const { colorSchemePref, setColorSchemePref } = useTheme(); const toggleColorScheme = () => { switch (colorSchemePref) { case "": - case "system": - setColorSchemePref("dark"); + case SYSTEM: + setColorSchemePref(DARK); break; - case "dark": - setColorSchemePref("light"); + case DARK: + setColorSchemePref(LIGHT); break; - case "light": - setColorSchemePref(skipSystem ? "dark" : "system"); + case LIGHT: + setColorSchemePref(skipSystem ? DARK : SYSTEM); } }; return ( @@ -47,4 +48,4 @@ export function ColorSwitch({ size = 25, skipSystem }: ColorSwitchProps) { style={{ "--size": `${size}px` }} /> ); -} +}; diff --git a/lib/nextjs-themes/src/client/force-color-scheme/force-color-scheme.test.tsx b/lib/nextjs-themes/src/client/force-color-scheme/force-color-scheme.test.tsx index d6fefe65..7ffef8b1 100644 --- a/lib/nextjs-themes/src/client/force-color-scheme/force-color-scheme.test.tsx +++ b/lib/nextjs-themes/src/client/force-color-scheme/force-color-scheme.test.tsx @@ -2,15 +2,16 @@ import { act, cleanup, render, renderHook } from "@testing-library/react"; import { afterEach, describe, test } from "vitest"; import { useTheme } from "../../hooks"; import { ForceColorScheme } from "./force-color-scheme"; +import { DARK, LIGHT } from "../../constants"; describe.concurrent("force-color-scheme", () => { afterEach(cleanup); /** Test only the things that this component is responsible for - chanding state*/ test("Force theme with force color scheme", async ({ expect }) => { const { result } = renderHook(() => useTheme()); - act(() => result.current.setForcedColorScheme("light")); - const { unmount } = await act(() => render()); - expect(result.current.forcedColorScheme).toBe("dark"); + act(() => result.current.setForcedColorScheme(LIGHT)); + const { unmount } = await act(() => render()); + expect(result.current.forcedColorScheme).toBe(DARK); act(() => unmount()); expect(result.current.forcedColorScheme).toBe(undefined); }); diff --git a/lib/nextjs-themes/src/client/force-color-scheme/force-color-scheme.tsx b/lib/nextjs-themes/src/client/force-color-scheme/force-color-scheme.tsx index 5aa2e51e..0d9514d8 100644 --- a/lib/nextjs-themes/src/client/force-color-scheme/force-color-scheme.tsx +++ b/lib/nextjs-themes/src/client/force-color-scheme/force-color-scheme.tsx @@ -1,10 +1,10 @@ "use client"; -import * as React from "react"; import { useEffect } from "react"; import type { ColorSchemeType } from "../../constants"; import { useTheme } from "../../hooks"; -export function ForceColorScheme(props: { colorScheme: ColorSchemeType }) { +/** Force color scheme on a page */ +export const ForceColorScheme = (props: { colorScheme: ColorSchemeType }) => { const { setForcedColorScheme } = useTheme(); useEffect(() => { setForcedColorScheme(props.colorScheme); @@ -13,4 +13,4 @@ export function ForceColorScheme(props: { colorScheme: ColorSchemeType }) { }; }, [props.colorScheme]); return null; -} +}; diff --git a/lib/nextjs-themes/src/client/force-theme/force-theme.tsx b/lib/nextjs-themes/src/client/force-theme/force-theme.tsx index da10db80..0b163b3e 100644 --- a/lib/nextjs-themes/src/client/force-theme/force-theme.tsx +++ b/lib/nextjs-themes/src/client/force-theme/force-theme.tsx @@ -1,9 +1,9 @@ "use client"; -import * as React from "react"; import { useEffect } from "react"; import { useTheme } from "../../hooks"; -export function ForceTheme(props: { theme: string }) { +/** Force theme on a page */ +export const ForceTheme = (props: { theme: string }) => { const { setForcedTheme } = useTheme(); useEffect(() => { setForcedTheme(props.theme); @@ -12,4 +12,4 @@ export function ForceTheme(props: { theme: string }) { }; }, [props.theme]); return null; -} +}; diff --git a/lib/nextjs-themes/src/client/theme-switcher/theme-switcher.test.tsx b/lib/nextjs-themes/src/client/theme-switcher/theme-switcher.test.tsx index 86cc1be7..3ff4668d 100644 --- a/lib/nextjs-themes/src/client/theme-switcher/theme-switcher.test.tsx +++ b/lib/nextjs-themes/src/client/theme-switcher/theme-switcher.test.tsx @@ -2,9 +2,15 @@ import { RenderHookResult, act, cleanup, fireEvent, render, renderHook } from "@ import { afterEach, beforeEach, describe, test } from "vitest"; import { useTheme } from "../../hooks"; import { ThemeSwitcher } from "./theme-switcher"; -import { encodeState, getResolvedColorScheme, getResolvedTheme } from "../../utils"; import useRGS, { SetterArgType } from "r18gs"; -import { DEFAULT_ID, ThemeStoreType, initialState } from "../../constants"; +import { DARK, DEFAULT_ID, LIGHT, ThemeStoreType, initialState } from "../../constants"; +import { MEDIA } from "../../utils"; + +/** get dom attribute */ +const getResolvedTheme = () => { + const theme = document.documentElement.getAttribute("data-theme"); + return theme; +}; /** * -> concurrency is not feasible because of global store conflicts @@ -35,7 +41,7 @@ describe("theme-switcher", () => { test("Test defaultDark theme", async ({ expect }) => { const darkTheme = "dark1"; /** simulate system dark mode */ - act(() => rgsHook.result.current[1](state => ({ ...state, systemColorScheme: "dark" }))); + act(() => rgsHook.result.current[1](state => ({ ...state, systemColorScheme: DARK }))); /** simulate changing darkTheme by another component by user */ await new Promise(res => setTimeout(res, 350)); const { result } = renderHook(() => useTheme()); @@ -56,13 +62,13 @@ describe("theme-switcher", () => { test("test color scheme preference", async ({ expect }) => { const { result } = renderHook(() => useTheme()); - act(() => result.current.setColorSchemePref("light")); + act(() => result.current.setColorSchemePref(LIGHT)); act(() => result.current.setLightTheme("yellow")); act(() => result.current.setDarkTheme("dark-blue")); act(() => result.current.setTheme("blue")); await new Promise(res => setTimeout(res, 250)); expect(getResolvedTheme()).toBe("yellow"); - act(() => result.current.setColorSchemePref("dark")); + act(() => result.current.setColorSchemePref(DARK)); /** note we do not require to await second time -- ?? what is user is setting theme from multuple useEffects? */ expect(getResolvedTheme()).toBe("dark-blue"); }); @@ -89,7 +95,6 @@ describe("theme-switcher", () => { expect(getResolvedTheme()).toBe(""); act(() => result.current.setForcedTheme(undefined)); expect(getResolvedTheme()).toBe("black"); - expect(getResolvedColorScheme()).toBe("dark"); }); test("Storage event", async ({ expect }) => { @@ -98,7 +103,10 @@ describe("theme-switcher", () => { await act(() => fireEvent( window, - new StorageEvent("storage", { key: DEFAULT_ID, newValue: encodeState({ ...initialState, theme: MY_THEME }) }), + new StorageEvent("storage", { + key: DEFAULT_ID, + newValue: JSON.stringify({ ...initialState, theme: MY_THEME }), + }), ), ); expect(hook.result.current.theme).toBe(MY_THEME); @@ -125,6 +133,16 @@ describe("theme-switcher with props", () => { test("forced colorScheme prop", async ({ expect }) => { // global state is continuing from previous testss await act(() => render()); - expect(getResolvedColorScheme()).toBe("light"); + expect(getResolvedTheme()).toBe(""); + }); + + test("media change event", async ({ expect }) => { + await act(() => render()); + await act(() => { + // globalThis.window.media = LIGHT as ResolvedScheme; + // @ts-expect-error -- ok + matchMedia(MEDIA).onchange?.(); + }); + expect(getResolvedTheme()).toBe(DARK); }); }); diff --git a/lib/nextjs-themes/src/client/theme-switcher/theme-switcher.tsx b/lib/nextjs-themes/src/client/theme-switcher/theme-switcher.tsx index 6a050d91..836c602d 100644 --- a/lib/nextjs-themes/src/client/theme-switcher/theme-switcher.tsx +++ b/lib/nextjs-themes/src/client/theme-switcher/theme-switcher.tsx @@ -1,10 +1,7 @@ -import * as React from "react"; import { useEffect } from "react"; -import { resolveTheme, parseState, encodeState } from "../../utils"; -import { ColorSchemeType, DEFAULT_ID, ThemeStoreType, initialState } from "../../constants"; -import useRGS, { SetStateAction } from "r18gs"; +import { resolveTheme, MEDIA, useStore } from "../../utils"; +import { ColorSchemeType, DARK, DEFAULT_ID, LIGHT, ResolvedColorSchemeType } from "../../constants"; -/** todo - set persistance and cookies */ export interface ThemeSwitcherProps { forcedTheme?: string; forcedColorScheme?: ColorSchemeType; @@ -14,51 +11,18 @@ export interface ThemeSwitcherProps { styles?: Record; } -function useMediaQuery(setThemeState: SetStateAction) { - React.useEffect(() => { - // set event listener for media - const media = matchMedia("(prefers-color-scheme: dark)"); - const updateSystemColorScheme = () => { - setThemeState(state => ({ ...state, systemColorScheme: media.matches ? "dark" : "light" })); - }; - updateSystemColorScheme(); - media.addEventListener("change", updateSystemColorScheme); - return () => { - media.removeEventListener("change", updateSystemColorScheme); - }; - }, [setThemeState]); -} - -let tInit = 0; -function useLoadSyncedState(setThemeState: SetStateAction, targetSelector?: string) { - React.useEffect(() => { - tInit = Date.now(); - const key = targetSelector ?? DEFAULT_ID; - const storedState = parseState(localStorage.getItem(key)); - // @ts-ignore - delete storedState.systemColorScheme; - setThemeState(state => ({ ...state, ...storedState })); - const storageListener = (e: StorageEvent) => { - if (e.key === key) setThemeState(state => ({ ...state, ...parseState(e.newValue) })); - }; - window.addEventListener("storage", storageListener); - return () => { - window.removeEventListener("storage", storageListener); - }; - }, [targetSelector]); -} - export interface DataProps { className: string; "data-th"?: string; "data-theme"?: string; - "data-color-scheme"?: "dark" | "light"; - "data-csp"?: ColorSchemeType /** color-scheme-preference */; + "data-color-scheme"?: ResolvedColorSchemeType; + /** color-scheme-preference */ + "data-csp"?: ColorSchemeType; } export interface UpdateProps { resolvedTheme: string; - resolvedColorScheme: "dark" | "light"; + resolvedColorScheme: ResolvedColorSchemeType; resolvedColorSchemePref: ColorSchemeType; th: string; } @@ -84,22 +48,18 @@ const updateDOM = ( return shouldCreateCookie; }; -const disableAnimation = (themeTransition = "none") => { +/** disable transition while switching theme */ +const disableTransition = (themeTransition = "none") => { const css = document.createElement("style"); /** split by ';' to prevent CSS injection */ - const transition = `transition: ${themeTransition.split(";")[0]} !important;`; - css.appendChild( - document.createTextNode(`*{-webkit-${transition}-moz-${transition}-o-${transition}-ms-${transition}${transition}}`), - ); + css.textContent = `transition: ${themeTransition.split(";")[0]} !important;`; document.head.appendChild(css); return () => { // Force restyle - (() => window.getComputedStyle(document.body))(); + getComputedStyle(document.body); // Wait for next tick before removing - setTimeout(() => { - document.head.removeChild(css); - }, 1); + setTimeout(() => document.head.removeChild(css), 1); }; }; @@ -107,31 +67,37 @@ const disableAnimation = (themeTransition = "none") => { * You can use this hook in place of `` component. * Please note that you need to add "use client" on top of the component in which you are using this hook. */ -export function useThemeSwitcher(props: ThemeSwitcherProps) { - const [themeState, setThemeState] = useRGS(props.targetSelector ?? DEFAULT_ID, initialState); - - useMediaQuery(setThemeState); - useLoadSyncedState(setThemeState, props.targetSelector); +export const useThemeSwitcher = (props: ThemeSwitcherProps) => { + const [themeState, setThemeState] = useStore(props.targetSelector); + // not using ?? as we don't want key to be an empty string ever + const key = props.targetSelector || DEFAULT_ID; + /** set listeners for system preference and syncing store */ useEffect(() => { - const restoreTransitions = disableAnimation(props.themeTransition); + const media = matchMedia(MEDIA); + media.addEventListener("change", () => + setThemeState(state => ({ ...state, systemColorScheme: media.matches ? DARK : LIGHT })), + ); + addEventListener("storage", e => { + if (e.key === key) setThemeState(state => ({ ...state, ...JSON.parse(e.newValue || "{}") })); + }); + }, []); + useEffect(() => { + const restoreTransitions = disableTransition(props.themeTransition); const resolvedData = resolveTheme(themeState, props); const shouldCreateCookie = updateDOM(resolvedData, props); - if (tInit < Date.now() - 300) { - const stateStr = encodeState(themeState); - const key = props.targetSelector || DEFAULT_ID; - localStorage.setItem(key, stateStr); - if (shouldCreateCookie) document.cookie = `${key}=${stateStr}; max-age=31536000; SameSite=Strict;`; - } + const stateStr = JSON.stringify(themeState); + localStorage.setItem(key, stateStr); + if (shouldCreateCookie) document.cookie = `${key}=${stateStr};max-age=31536000;SameSite=Strict;`; restoreTransitions(); }, [props, themeState]); -} +}; /** * Use this component in your layout - `app/layout.tsx` or your custom layout or in `_app.tsx` file. * @component */ -export function ThemeSwitcher(props: ThemeSwitcherProps) { +export const ThemeSwitcher = (props: ThemeSwitcherProps) => { useThemeSwitcher(props); return null; -} +}; diff --git a/lib/nextjs-themes/src/constants.ts b/lib/nextjs-themes/src/constants.ts index 136769c2..fad01b90 100644 --- a/lib/nextjs-themes/src/constants.ts +++ b/lib/nextjs-themes/src/constants.ts @@ -1,7 +1,11 @@ /** shared constants -- keep in separate files for better tree-shaking and dependency injection */ -export const DEFAULT_ID = "nth"; +export const DEFAULT_ID = "nth-1"; +export const LIGHT = "light"; +export const DARK = "dark"; +export const SYSTEM = "system"; export type ColorSchemeType = "" | "system" | "dark" | "light"; +export type ResolvedColorSchemeType = "dark" | "light"; export interface ThemeStoreType { theme: string; @@ -25,8 +29,8 @@ export type ThemeStoreActionsType = { export const initialState: ThemeStoreType = { theme: "", - darkTheme: "dark", + darkTheme: DARK, lightTheme: "", - colorSchemePref: "system", - systemColorScheme: "light", + colorSchemePref: SYSTEM, + systemColorScheme: LIGHT, }; diff --git a/lib/nextjs-themes/src/hooks/use-theme.test.ts b/lib/nextjs-themes/src/hooks/use-theme.test.ts new file mode 100644 index 00000000..93015709 --- /dev/null +++ b/lib/nextjs-themes/src/hooks/use-theme.test.ts @@ -0,0 +1,11 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, test } from "vitest"; +import { useTheme } from "./use-theme"; + +describe("use-theme", () => { + test("setThemeSet", ({ expect }) => { + const hook = renderHook(() => useTheme()); + act(() => hook.result.current.setThemeSet({ darkTheme: "dark-1", lightTheme: "light-1" })); + expect(hook.result.current.darkTheme).toBe("dark-1"); + }); +}); diff --git a/lib/nextjs-themes/src/hooks/use-theme.ts b/lib/nextjs-themes/src/hooks/use-theme.ts index 7ef90467..fe0b5adb 100644 --- a/lib/nextjs-themes/src/hooks/use-theme.ts +++ b/lib/nextjs-themes/src/hooks/use-theme.ts @@ -1,40 +1,31 @@ import useRGS, { SetStateAction } from "r18gs"; import { ColorSchemeType, DEFAULT_ID, ThemeStoreType, initialState } from "../constants"; import { resolveTheme } from "../utils"; -import { useCallback, useMemo } from "react"; +import { useMemo } from "react"; -const DELAY = 200; // ms - delay to allow reading from localStorage so that local storage does not override the new state -const map: Record = {}; -function createSetterWithFirstTimeDelay(setThemeState: SetStateAction) { - return (key: string) => { - if (map[key] === undefined) { - map[key] = 1; - return (arg: T) => setTimeout(() => setThemeState(state => ({ ...state, [key]: arg })), DELAY); - } else if (map[key] === 1) { - const fn = (arg: T) => setThemeState(state => ({ ...state, [key]: arg })); - map[key] = fn; - } - return map[key] as (arg: T) => void; - }; -} +/** create setter for memoized setter */ +const createSetter = (setThemeState: SetStateAction) => { + return (key: string) => + (arg: T) => + setThemeState(state => ({ ...state, [key]: arg })); +}; -export function useTheme(targetId?: string) { +/** useTheme hook */ +export const useTheme = (targetId?: string) => { const [themeState, setThemeState] = useRGS(targetId ?? DEFAULT_ID, initialState); const { resolvedColorScheme, resolvedTheme } = resolveTheme(themeState); - const setterWithFirstTimeDelay = useMemo(() => createSetterWithFirstTimeDelay(setThemeState), []); + const setter = useMemo(() => createSetter(setThemeState), []); return { ...themeState, resolvedColorScheme, resolvedTheme, - setTheme: setterWithFirstTimeDelay("theme"), - setDarkTheme: setterWithFirstTimeDelay("darkTheme"), - setLightTheme: setterWithFirstTimeDelay("lightTheme"), - setThemeSet: useCallback( - (themeSet: { darkTheme: string; lightTheme: string }) => setThemeState(state => ({ ...state, ...themeSet })), - [], - ), - setColorSchemePref: setterWithFirstTimeDelay("colorSchemePref"), - setForcedTheme: setterWithFirstTimeDelay("forcedTheme"), - setForcedColorScheme: setterWithFirstTimeDelay("forcedColorScheme"), + setTheme: setter("theme"), + setDarkTheme: setter("darkTheme"), + setLightTheme: setter("lightTheme"), + setThemeSet: (themeSet: { darkTheme: string; lightTheme: string }) => + setThemeState(state => ({ ...state, ...themeSet })), + setColorSchemePref: setter("colorSchemePref"), + setForcedTheme: setter("forcedTheme"), + setForcedColorScheme: setter("forcedColorScheme"), }; -} +}; diff --git a/lib/nextjs-themes/src/server/nextjs/server-side-wrapper/server-side-wrapper.test.tsx b/lib/nextjs-themes/src/server/nextjs/server-side-wrapper/server-side-wrapper.test.tsx index af518be3..22d492a6 100644 --- a/lib/nextjs-themes/src/server/nextjs/server-side-wrapper/server-side-wrapper.test.tsx +++ b/lib/nextjs-themes/src/server/nextjs/server-side-wrapper/server-side-wrapper.test.tsx @@ -2,7 +2,6 @@ import { cleanup, render, screen } from "@testing-library/react"; import { afterEach, describe, test, beforeEach } from "vitest"; import { NextJsSSGThemeSwitcher, ServerSideWrapper } from "."; import { DEFAULT_ID } from "../../../constants"; -import { encodeState } from "../../../utils"; describe("server-side-target", () => { afterEach(cleanup); @@ -10,12 +9,12 @@ describe("server-side-target", () => { beforeEach(() => { globalThis.cookies = { [DEFAULT_ID]: { - value: encodeState({ + value: JSON.stringify({ theme: "yellow", darkTheme: "dark-blue", lightTheme: "light-yellow", colorSchemePref: "dark", - systemColorScheme: "dark" + systemColorScheme: "dark", }), }, }; diff --git a/lib/nextjs-themes/src/server/nextjs/server-side-wrapper/server-side-wrapper.tsx b/lib/nextjs-themes/src/server/nextjs/server-side-wrapper/server-side-wrapper.tsx index 6be0eaf5..f1947458 100644 --- a/lib/nextjs-themes/src/server/nextjs/server-side-wrapper/server-side-wrapper.tsx +++ b/lib/nextjs-themes/src/server/nextjs/server-side-wrapper/server-side-wrapper.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import type { HTMLProps, ReactNode } from "react"; import { cookies, headers } from "next/headers"; import { DEFAULT_ID, type ColorSchemeType, type ThemeStoreType } from "../../../constants"; -import { parseState, resolveTheme } from "../../../utils"; +import { resolveTheme } from "../../../utils"; import { DataProps, UpdateProps } from "../../../client"; export type ForcedPage = @@ -20,7 +20,8 @@ export interface NextJsSSRThemeSwitcherProps extends HTMLProps { styles?: Record; } -function getDataProps(resolvedData: UpdateProps, styles?: Record) { +/** getData props */ +const getDataProps = (resolvedData: UpdateProps, styles?: Record) => { const dataProps: DataProps = { className: "" }; let classeNames = []; if (resolvedData.resolvedColorScheme !== undefined) { @@ -42,12 +43,14 @@ function getDataProps(resolvedData: UpdateProps, styles?: Record if (styles) classeNames = classeNames.map(cls => styles[cls] ?? cls); dataProps.className = classeNames.join(" "); return dataProps; -} +}; -function sharedServerComponentRenderer( +/** Shared server component renderer for Next.js SSG and SSR */ +/** @internal */ +const sharedServerComponentRenderer = ( { children, tag, forcedPages, targetId, styles, ...props }: NextJsSSRThemeSwitcherProps, defaultTag: "div" | "html", -) { +) => { const Tag: keyof JSX.IntrinsicElements = tag || defaultTag; const key = targetId ? `#${targetId}` : DEFAULT_ID; const state = cookies().get(key)?.value; @@ -59,7 +62,7 @@ function sharedServerComponentRenderer( const forcedPageProps = Array.isArray(forcedPage) ? { forcedTheme: forcedPage[1].theme, forcedColorScheme: forcedPage[1].colorScheme } : forcedPage?.props; - const themeState = state ? (parseState(state) as ThemeStoreType) : undefined; + const themeState = state ? (JSON.parse(state) as ThemeStoreType) : undefined; const resolvedData = resolveTheme(themeState, forcedPageProps); const dataProps = getDataProps(resolvedData, styles); if (targetId) dataProps.className += styles?.[" nth-scoped"] ?? " nth-scoped"; @@ -70,7 +73,7 @@ function sharedServerComponentRenderer( {children} ); -} +}; /** * @example @@ -78,9 +81,9 @@ function sharedServerComponentRenderer( * * ``` */ -export function NextJsSSGThemeSwitcher(props: NextJsSSRThemeSwitcherProps) { +export const NextJsSSGThemeSwitcher = (props: NextJsSSRThemeSwitcherProps) => { return sharedServerComponentRenderer(props, "div"); -} +}; /** For naming consistancy, clarity, and minimizing API updates */ export { NextJsSSGThemeSwitcher as NextJsServerTarget }; @@ -102,6 +105,6 @@ export interface ServerSideWrapperProps extends NextJsSSRThemeSwitcherProps { * * ``` */ -export function ServerSideWrapper(props: ServerSideWrapperProps) { +export const ServerSideWrapper = (props: ServerSideWrapperProps) => { return sharedServerComponentRenderer(props, "html"); -} +}; diff --git a/lib/nextjs-themes/src/utils.ts b/lib/nextjs-themes/src/utils.ts index 0078b198..61065fc4 100644 --- a/lib/nextjs-themes/src/utils.ts +++ b/lib/nextjs-themes/src/utils.ts @@ -1,51 +1,44 @@ +import useRGS from "r18gs"; import { ThemeSwitcherProps, UpdateProps } from "./client"; -import { ColorSchemeType, ThemeStoreType, initialState } from "./constants"; +import { DARK, DEFAULT_ID, LIGHT, ResolvedColorSchemeType, SYSTEM, ThemeStoreType, initialState } from "./constants"; -export function resolveTheme(state?: ThemeStoreType, props?: ThemeSwitcherProps): UpdateProps { - const resolvedForcedTheme = props?.forcedTheme === undefined ? state?.forcedTheme : props.forcedTheme; - const resolvedForcedColorScheme = - props?.forcedColorScheme === undefined ? state?.forcedColorScheme : props.forcedColorScheme; - const resolvedColorSchemePref = - (resolvedForcedColorScheme === undefined ? state?.colorSchemePref : resolvedForcedColorScheme) || ""; +/** resolve props and state to a final attributes that should be applied to the DOM */ +export const resolveTheme = (state?: ThemeStoreType, props?: ThemeSwitcherProps): UpdateProps => { + const resolvedForcedTheme = props?.forcedTheme ?? state?.forcedTheme; + const resolvedForcedColorScheme = props?.forcedColorScheme ?? state?.forcedColorScheme; + const resolvedColorSchemePref = (resolvedForcedColorScheme ?? state?.colorSchemePref) || ""; - const isSystemDark = state?.systemColorScheme === "dark"; + const isSystemDark = state?.systemColorScheme === DARK; - let resolvedColorScheme: "dark" | "light" = isSystemDark ? "dark" : "light"; - let resolvedTheme = resolvedForcedTheme === undefined ? state?.theme || "" : resolvedForcedTheme; + /** these will be modified in the switch statement */ + let resolvedColorScheme: ResolvedColorSchemeType = isSystemDark ? DARK : LIGHT; + let resolvedTheme = (resolvedForcedTheme ?? state?.theme) || ""; if (resolvedForcedTheme === undefined) switch (resolvedColorSchemePref) { - case "system": + case SYSTEM: resolvedTheme = (isSystemDark ? state?.darkTheme : state?.lightTheme) || ""; break; - case "dark": - [resolvedTheme, resolvedColorScheme] = [state?.darkTheme || "", "dark"]; + case DARK: + [resolvedTheme, resolvedColorScheme] = [state?.darkTheme || "", DARK]; break; - case "light": - [resolvedTheme, resolvedColorScheme] = [state?.lightTheme || "", "light"]; + case LIGHT: + [resolvedTheme, resolvedColorScheme] = [state?.lightTheme || "", LIGHT]; break; } const th = resolvedForcedTheme === undefined ? state?.theme || "" : resolvedForcedTheme; return { resolvedTheme, resolvedColorScheme, resolvedColorSchemePref, th }; -} - -export function getResolvedTheme() { - return document.documentElement.getAttribute("data-theme"); -} - -export function getResolvedColorScheme() { - return document.documentElement.getAttribute("data-color-scheme"); -} - -export function encodeState(themeState: ThemeStoreType) { - const { colorSchemePref, systemColorScheme, darkTheme, lightTheme, theme } = themeState; - return [colorSchemePref, systemColorScheme, darkTheme, lightTheme, theme].join(","); -} - -export function parseState(str?: string | null): ThemeStoreType { - if(!str) return initialState; - type StrSplitType = [ColorSchemeType, "dark" | "light", string, string, string]; - const [colorSchemePref, systemColorScheme, darkTheme, lightTheme, theme] = str.split(",") as StrSplitType; - return { colorSchemePref, systemColorScheme, darkTheme, lightTheme, theme }; -} +}; + +const isServer = typeof localStorage === "undefined"; +export const MEDIA = "(prefers-color-scheme: dark)"; + +/** internal store API */ +export const useStore = (targetId?: string) => { + const key = targetId ?? DEFAULT_ID; + return useRGS(key, () => { + const str = isServer ? null : localStorage.getItem(key); + return str ? { ...JSON.parse(str), systemColorScheme: matchMedia(MEDIA).matches ? DARK : LIGHT } : initialState; + }); +}; diff --git a/lib/nextjs-themes/turbo/generators/templates/component.hbs b/lib/nextjs-themes/turbo/generators/templates/component.hbs index 2dd8909e..59d62c0d 100644 --- a/lib/nextjs-themes/turbo/generators/templates/component.hbs +++ b/lib/nextjs-themes/turbo/generators/templates/component.hbs @@ -9,7 +9,7 @@ interface {{ pascalCase name }}Props { * @example * <{{pascalCase name}} /> */ -export function {{ pascalCase name }}({ children }: {{ pascalCase name }}Props) { +export const {{ pascalCase name }} = ({ children }: {{ pascalCase name }}Props) => { return (

{{ name }}

diff --git a/lib/nextjs-themes/vitest.setup.ts b/lib/nextjs-themes/vitest.setup.ts index 42546a0b..d6a1f43f 100644 --- a/lib/nextjs-themes/vitest.setup.ts +++ b/lib/nextjs-themes/vitest.setup.ts @@ -1,14 +1,19 @@ import { vi } from "vitest"; +const mediaListeners: (() => void)[] = []; // mock matchMedia Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn().mockImplementation((query: string) => ({ matches: query.includes(window.media), media: query, - onchange: null, - addEventListener: vi.fn(), - removeEventListener: vi.fn(), + onchange() { + this.matches = query.includes(window.media); + mediaListeners.forEach(listener => listener()); + }, + addEventListener: (_: string, listener: () => void) => mediaListeners.push(listener), + removeEventListener: (_: string, listener: () => void) => + mediaListeners.splice(mediaListeners.indexOf(listener), 1), dispatchEvent: vi.fn(), })), }); diff --git a/packages/shared-ui/src/globals.css b/packages/shared-ui/src/globals.css index a37611fc..50782132 100644 --- a/packages/shared-ui/src/globals.css +++ b/packages/shared-ui/src/globals.css @@ -1,3 +1,5 @@ +@import "nextjs-themes/src/styles.css"; + * { box-sizing: border-box; }