Skip to content

Commit

Permalink
build setup improvements
Browse files Browse the repository at this point in the history
  • Loading branch information
pacocoursey committed Mar 12, 2024
1 parent 40e57a9 commit 7db4f3d
Show file tree
Hide file tree
Showing 13 changed files with 1,568 additions and 4,736 deletions.
Original file line number Diff line number Diff line change
@@ -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<string, string> = {}

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)
}
Expand All @@ -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', () => {
Expand Down Expand Up @@ -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', () => {
Expand All @@ -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')
})
})

Expand All @@ -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'", () => {
Expand All @@ -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')
})
})

Expand Down Expand Up @@ -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(
Expand All @@ -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)', () => {
Expand Down Expand Up @@ -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(
<ThemeProvider>
Expand All @@ -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(
Expand Down
File renamed without changes.
29 changes: 29 additions & 0 deletions next-themes/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
52 changes: 22 additions & 30 deletions packages/next-themes/src/index.tsx → next-themes/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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<UseThemeProps | undefined>(undefined)
const ThemeContext = React.createContext<UseThemeProps | undefined>(undefined)
const defaultContext: UseThemeProps = { setTheme: _ => {}, themes: [] }

export const useTheme = () => useContext(ThemeContext) ?? defaultContext
export const useTheme = () => React.useContext(ThemeContext) ?? defaultContext

export const ThemeProvider: React.FC<ThemeProviderProps> = 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 <Fragment>{props.children}</Fragment>
if (context) return props.children
return <Theme {...props} />
}

const defaultThemes = ['light', 'dark']

const Theme: React.FC<ThemeProviderProps> = ({
const Theme = ({
forcedTheme,
disableTransitionOnChange = false,
enableSystem = true,
Expand All @@ -40,12 +33,12 @@ const Theme: React.FC<ThemeProviderProps> = ({
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

Expand Down Expand Up @@ -80,7 +73,7 @@ const Theme: React.FC<ThemeProviderProps> = ({
enable?.()
}, [])

const setTheme = useCallback(
const setTheme = React.useCallback(
theme => {
const newTheme = typeof theme === 'function' ? theme(theme) : theme
setThemeState(newTheme)
Expand All @@ -95,7 +88,7 @@ const Theme: React.FC<ThemeProviderProps> = ({
[forcedTheme]
)

const handleMediaQuery = useCallback(
const handleMediaQuery = React.useCallback(
(e: MediaQueryListEvent | MediaQueryList) => {
const resolved = getSystemTheme(e)
setResolvedTheme(resolved)
Expand All @@ -108,7 +101,7 @@ const Theme: React.FC<ThemeProviderProps> = ({
)

// Always listen to System preference
useEffect(() => {
React.useEffect(() => {
const media = window.matchMedia(MEDIA)

// Intentionally use deprecated listener methods to support iOS & old browsers
Expand All @@ -119,7 +112,7 @@ const Theme: React.FC<ThemeProviderProps> = ({
}, [handleMediaQuery])

// localStorage event handling
useEffect(() => {
React.useEffect(() => {
const handleStorage = (e: StorageEvent) => {
if (e.key !== storageKey) {
return
Expand All @@ -135,11 +128,11 @@ const Theme: React.FC<ThemeProviderProps> = ({
}, [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,
Expand Down Expand Up @@ -169,12 +162,13 @@ const Theme: React.FC<ThemeProviderProps> = ({
nonce
}}
/>

{children}
</ThemeContext.Provider>
)
}

const ThemeScript = memo(
const ThemeScript = React.memo(
({
forcedTheme,
storageKey,
Expand Down Expand Up @@ -265,9 +259,7 @@ const ThemeScript = memo(
})()

return <script nonce={nonce} dangerouslySetInnerHTML={{ __html: scriptSrc }} />
},
// Never re-render this component
() => true
}
)

// Helpers
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as React from 'react'

interface ValueObject {
[themeName: string]: string
}
Expand Down Expand Up @@ -38,6 +40,6 @@ export interface ThemeProviderProps {
value?: ValueObject | undefined
/** Nonce string to pass to the inline script for CSP headers */
nonce?: string | undefined

children?: React.ReactNode
/** React children */
children: React.ReactNode
}
20 changes: 20 additions & 0 deletions next-themes/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es2018",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": false,
"forceConsistentCasingInFileNames": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react",
"noEmit": true,
"types": ["vitest/jsdom"]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
"exclude": ["node_modules", "build", "dist", ".next"]
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { defineConfig } from 'tsup'

export default defineConfig({
entry: {
index: 'src/index.tsx'
},
sourcemap: false,
minify: true,
banner: {
js: `'use client'`
},
dts: true,
clean: true,
external: ['react'],
format: ['esm', 'cjs'],
loader: {
'.js': 'jsx'
Expand Down
Loading

0 comments on commit 7db4f3d

Please sign in to comment.