Skip to content

Commit

Permalink
v0.3 (#261)
Browse files Browse the repository at this point in the history
* build setup improvements

* fix builds, update deps

* update dep

* update test workflow

* bump actions in e2e

* 0.3.0-beta.1
pacocoursey authored Mar 13, 2024
1 parent 40e57a9 commit f5df966
Showing 19 changed files with 1,375 additions and 4,351 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
@@ -8,13 +8,13 @@ jobs:
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@v3
with:
version: 8

- uses: actions/setup-node@v2
- uses: actions/setup-node@v3
with:
node-version: 18.x
cache: 'pnpm'
@@ -31,7 +31,7 @@ jobs:
- name: Cache Playwright Browsers for Playwright's Version
id: cache-playwright-browsers
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-browsers-${{ env.PLAYWRIGHT_VERSION }}
8 changes: 4 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -7,15 +7,15 @@ jobs:
name: Run Unit Tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4

- uses: pnpm/action-setup@v2
- uses: pnpm/action-setup@v3
with:
version: 8

- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: 18.x
node-version: 20.x
cache: 'pnpm'

- run: pnpm install
2 changes: 1 addition & 1 deletion examples/example/package.json
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@
"start": "next start"
},
"dependencies": {
"next": "12.2.2",
"next": "^13.4.19",
"next-themes": "workspace:*",
"react": "18.2.0",
"react-dom": "18.2.0"
2 changes: 1 addition & 1 deletion examples/tailwind/package.json
Original file line number Diff line number Diff line change
@@ -7,7 +7,7 @@
"start": "next start"
},
"dependencies": {
"next": "12.2.2",
"next": "^13.4.19",
"next-themes": "workspace:*",
"react": "18.2.0",
"react-dom": "18.2.0"
22 changes: 9 additions & 13 deletions examples/with-app-dir/package.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,20 @@
{
"name": "with-app-dir",
"version": "0.1.0",
"private": true,
"version": "1.0.0",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
"start": "next start"
},
"dependencies": {
"@types/node": "20.5.7",
"@types/react": "^18.2.21",
"@types/react-dom": "18.2.7",
"autoprefixer": "10.4.15",
"next": "^13.4.19",
"next-themes": "workspace:*",
"postcss": "8.4.28",
"react": "18.2.0",
"react-dom": "18.2.0",
"tailwindcss": "3.3.3",
"typescript": "^5.2.2"
"react-dom": "18.2.0"
},
"devDependencies": {
"autoprefixer": "10.4.15",
"postcss": "8.4.28",
"tailwindcss": "3.3.3"
}
}
}
4 changes: 2 additions & 2 deletions examples/with-app-dir/src/components/ThemeToggle.tsx
Original file line number Diff line number Diff line change
@@ -3,13 +3,13 @@
import { useTheme } from 'next-themes'

function ThemeToggle() {
const { theme, setTheme } = useTheme()
const { theme, resolvedTheme, setTheme } = useTheme()

return (
<button
className="mt-16 px-4 py-2 text-white dark:text-black bg-black dark:bg-white font-semibold rounded-md"
onClick={() => {
setTheme(theme === 'light' ? 'dark' : 'light')
setTheme(resolvedTheme === 'light' ? 'dark' : 'light')
}}
>
Change Theme
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)
}
@@ -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(
<ThemeProvider>
@@ -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(
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.3.0-beta.1",
"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 run __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,
@@ -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

@@ -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)
@@ -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)
@@ -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
@@ -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
@@ -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,
@@ -169,12 +162,13 @@ const Theme: React.FC<ThemeProviderProps> = ({
nonce
}}
/>

{children}
</ThemeContext.Provider>
)
}

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

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

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

interface ValueObject {
[themeName: string]: string
}
@@ -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'
12 changes: 10 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -11,15 +11,23 @@
},
"devDependencies": {
"@playwright/test": "^1.37.1",
"@testing-library/react": "^14.2.1",
"@types/node": "20.5.7",
"@types/react": "^18.2.65",
"@types/react-dom": "18.2.7",
"jsdom": "^24.0.0",
"prettier": "^2.2.1",
"turbo": "^1.10.12"
"tsup": "^8.0.2",
"turbo": "^1.10.12",
"typescript": "^5.4.2",
"vitest": "^1.3.1"
},
"repository": {
"type": "git",
"url": "https://github.com/pacocoursey/next-themes.git"
},
"engines": {
"node": ">=18",
"node": ">=20",
"pnpm": ">=8"
},
"packageManager": "pnpm@8.6.3"
23 changes: 0 additions & 23 deletions packages/next-themes/.babelrc

This file was deleted.

42 changes: 0 additions & 42 deletions packages/next-themes/package.json

This file was deleted.

29 changes: 0 additions & 29 deletions packages/next-themes/tsconfig.json

This file was deleted.

5,370 changes: 1,205 additions & 4,165 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
packages:
- 'next-themes'
- 'examples/*'
- 'packages/*'

0 comments on commit f5df966

Please sign in to comment.