diff --git a/packages/next-themes/__tests__/index.test.tsx b/next-themes/__tests__/index.test.tsx similarity index 81% rename from packages/next-themes/__tests__/index.test.tsx rename to next-themes/__tests__/index.test.tsx index 58aaee88..5ca5cb9a 100644 --- a/packages/next-themes/__tests__/index.test.tsx +++ b/next-themes/__tests__/index.test.tsx @@ -1,14 +1,37 @@ -import { act, render, screen } from '@testing-library/react' -import { ThemeProvider, useTheme } from '../src' -import React, { useEffect } from 'react' +// @vitest-environment jsdom -let localStorageMock: { [key: string]: string } = {} +import * as React from 'react' +import { act, render, screen } from '@testing-library/react' +import { vi, beforeAll, beforeEach, afterEach, afterAll, describe, test, it, expect } from 'vitest' +import { cleanup } from '@testing-library/react' + +import { ThemeProvider, useTheme } from '../src/index' + +let originalLocalStorage: Storage +const localStorageMock: Storage = (() => { + let store: Record = {} + + return { + getItem: vi.fn((key: string): string => store[key] ?? null), + setItem: vi.fn((key: string, value: string): void => { + store[key] = value.toString() + }), + removeItem: vi.fn((key: string): void => { + delete store[key] + }), + clear: vi.fn((): void => { + store = {} + }), + key: vi.fn((index: number): string | null => ''), + length: Object.keys(store).length + } +})() // HelperComponent to render the theme inside a paragraph-tag and setting a theme via the forceSetTheme prop const HelperComponent = ({ forceSetTheme }: { forceSetTheme?: string }) => { const { setTheme, theme, forcedTheme, resolvedTheme, systemTheme } = useTheme() - useEffect(() => { + React.useEffect(() => { if (forceSetTheme) { setTheme(forceSetTheme) } @@ -29,36 +52,42 @@ function setDeviceTheme(theme: 'light' | 'dark') { // Based on: https://stackoverflow.com/questions/39830580/jest-test-fails-typeerror-window-matchmedia-is-not-a-function Object.defineProperty(window, 'matchMedia', { writable: true, - value: jest.fn().mockImplementation(query => ({ + value: vi.fn().mockImplementation(query => ({ matches: theme === 'dark' ? true : false, media: query, onchange: null, - addListener: jest.fn(), // Deprecated - removeListener: jest.fn(), // Deprecated - addEventListener: jest.fn(), - removeEventListener: jest.fn(), - dispatchEvent: jest.fn() + addListener: vi.fn(), // Deprecated + removeListener: vi.fn(), // Deprecated + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() })) }) } beforeAll(() => { // Create mocks of localStorage getItem and setItem functions - global.Storage.prototype.getItem = jest.fn((key: string) => localStorageMock[key]) - global.Storage.prototype.setItem = jest.fn((key: string, value: string) => { - localStorageMock[key] = value - }) + originalLocalStorage = window.localStorage + window.localStorage = localStorageMock }) beforeEach(() => { - // Reset global side-effects + // Reset window side-effects setDeviceTheme('light') document.documentElement.style.colorScheme = '' document.documentElement.removeAttribute('data-theme') document.documentElement.removeAttribute('class') // Clear the localStorage-mock - localStorageMock = {} + localStorageMock.clear() +}) + +afterEach(() => { + cleanup() +}) + +afterAll(() => { + window.localStorage = originalLocalStorage }) describe('defaultTheme', () => { @@ -129,8 +158,8 @@ describe('storage', () => { ) }) - expect(global.Storage.prototype.setItem).toBeCalledTimes(0) - expect(global.Storage.prototype.getItem('theme')).toBeUndefined() + expect(window.localStorage.setItem).toBeCalledTimes(0) + expect(window.localStorage.getItem('theme')).toBeNull() }) test('should set localStorage when switching themes', () => { @@ -142,8 +171,8 @@ describe('storage', () => { ) }) - expect(global.Storage.prototype.setItem).toBeCalledTimes(1) - expect(global.Storage.prototype.getItem('theme')).toBe('dark') + expect(window.localStorage.setItem).toBeCalledTimes(1) + expect(window.localStorage.getItem('theme')).toBe('dark') }) }) @@ -157,8 +186,8 @@ describe('custom storageKey', () => { ) }) - expect(global.Storage.prototype.getItem).toHaveBeenCalledWith('theme') - expect(global.Storage.prototype.setItem).toHaveBeenCalledWith('theme', 'light') + expect(window.localStorage.getItem).toHaveBeenCalledWith('theme') + expect(window.localStorage.setItem).toHaveBeenCalledWith('theme', 'light') }) test("should save to localStorage with 'custom' when setting prop 'storageKey' to 'customKey'", () => { @@ -170,8 +199,8 @@ describe('custom storageKey', () => { ) }) - expect(global.Storage.prototype.getItem).toHaveBeenCalledWith('customKey') - expect(global.Storage.prototype.setItem).toHaveBeenCalledWith('customKey', 'light') + expect(window.localStorage.getItem).toHaveBeenCalledWith('customKey') + expect(window.localStorage.setItem).toHaveBeenCalledWith('customKey', 'light') }) }) @@ -215,7 +244,7 @@ describe('custom attribute', () => { describe('custom value-mapping', () => { test('should use custom value mapping when using value={{pink:"my-pink-theme"}}', () => { - localStorageMock['theme'] = 'pink' + localStorageMock.setItem('theme', 'pink') act(() => { render( @@ -229,7 +258,7 @@ describe('custom value-mapping', () => { }) expect(document.documentElement.getAttribute('data-theme')).toBe('my-pink-theme') - expect(global.Storage.prototype.setItem).toHaveBeenCalledWith('theme', 'pink') + expect(window.localStorage.setItem).toHaveBeenCalledWith('theme', 'pink') }) test('should allow missing values (attribute)', () => { @@ -259,7 +288,7 @@ describe('custom value-mapping', () => { describe('forcedTheme', () => { test('should render saved theme when no forcedTheme is set', () => { - localStorageMock['theme'] = 'dark' + localStorageMock.setItem('theme', 'dark') render( @@ -272,7 +301,7 @@ describe('forcedTheme', () => { }) test('should render light theme when forcedTheme is set to light', () => { - localStorageMock['theme'] = 'dark' + localStorageMock.setItem('theme', 'dark') act(() => { render( diff --git a/packages/next-themes/jest.config.js b/next-themes/jest.config.js similarity index 100% rename from packages/next-themes/jest.config.js rename to next-themes/jest.config.js diff --git a/next-themes/package.json b/next-themes/package.json new file mode 100644 index 00000000..0c9375cd --- /dev/null +++ b/next-themes/package.json @@ -0,0 +1,29 @@ +{ + "name": "next-themes", + "version": "0.2.0", + "license": "MIT", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "prepublish": "pnpm build", + "build": "tsup src", + "dev": "tsup src --watch", + "test": "vitest __tests__" + }, + "peerDependencies": { + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18" + }, + "devDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/pacocoursey/next-themes.git" + } +} diff --git a/packages/next-themes/src/index.tsx b/next-themes/src/index.tsx similarity index 89% rename from packages/next-themes/src/index.tsx rename to next-themes/src/index.tsx index e606d2c5..eb073516 100644 --- a/packages/next-themes/src/index.tsx +++ b/next-themes/src/index.tsx @@ -1,34 +1,27 @@ -import React, { - Fragment, - createContext, - useCallback, - useContext, - useEffect, - useState, - useMemo, - memo -} from 'react' +'use client' + +import * as React from 'react' import type { UseThemeProps, ThemeProviderProps } from './types' const colorSchemes = ['light', 'dark'] const MEDIA = '(prefers-color-scheme: dark)' const isServer = typeof window === 'undefined' -const ThemeContext = createContext(undefined) +const ThemeContext = React.createContext(undefined) const defaultContext: UseThemeProps = { setTheme: _ => {}, themes: [] } -export const useTheme = () => useContext(ThemeContext) ?? defaultContext +export const useTheme = () => React.useContext(ThemeContext) ?? defaultContext -export const ThemeProvider: React.FC = props => { - const context = useContext(ThemeContext) +export const ThemeProvider = (props: ThemeProviderProps) => { + const context = React.useContext(ThemeContext) // Ignore nested context providers, just passthrough children - if (context) return {props.children} + if (context) return props.children return } const defaultThemes = ['light', 'dark'] -const Theme: React.FC = ({ +const Theme = ({ forcedTheme, disableTransitionOnChange = false, enableSystem = true, @@ -40,12 +33,12 @@ const Theme: React.FC = ({ value, children, nonce -}) => { - const [theme, setThemeState] = useState(() => getTheme(storageKey, defaultTheme)) - const [resolvedTheme, setResolvedTheme] = useState(() => getTheme(storageKey)) +}: ThemeProviderProps) => { + const [theme, setThemeState] = React.useState(() => getTheme(storageKey, defaultTheme)) + const [resolvedTheme, setResolvedTheme] = React.useState(() => getTheme(storageKey)) const attrs = !value ? themes : Object.values(value) - const applyTheme = useCallback(theme => { + const applyTheme = React.useCallback(theme => { let resolved = theme if (!resolved) return @@ -80,7 +73,7 @@ const Theme: React.FC = ({ enable?.() }, []) - const setTheme = useCallback( + const setTheme = React.useCallback( theme => { const newTheme = typeof theme === 'function' ? theme(theme) : theme setThemeState(newTheme) @@ -95,7 +88,7 @@ const Theme: React.FC = ({ [forcedTheme] ) - const handleMediaQuery = useCallback( + const handleMediaQuery = React.useCallback( (e: MediaQueryListEvent | MediaQueryList) => { const resolved = getSystemTheme(e) setResolvedTheme(resolved) @@ -108,7 +101,7 @@ const Theme: React.FC = ({ ) // Always listen to System preference - useEffect(() => { + React.useEffect(() => { const media = window.matchMedia(MEDIA) // Intentionally use deprecated listener methods to support iOS & old browsers @@ -119,7 +112,7 @@ const Theme: React.FC = ({ }, [handleMediaQuery]) // localStorage event handling - useEffect(() => { + React.useEffect(() => { const handleStorage = (e: StorageEvent) => { if (e.key !== storageKey) { return @@ -135,11 +128,11 @@ const Theme: React.FC = ({ }, [setTheme]) // Whenever theme or forcedTheme changes, apply it - useEffect(() => { + React.useEffect(() => { applyTheme(forcedTheme ?? theme) }, [forcedTheme, theme]) - const providerValue = useMemo( + const providerValue = React.useMemo( () => ({ theme, setTheme, @@ -169,12 +162,13 @@ const Theme: React.FC = ({ nonce }} /> + {children} ) } -const ThemeScript = memo( +const ThemeScript = React.memo( ({ forcedTheme, storageKey, @@ -265,9 +259,7 @@ const ThemeScript = memo( })() return