diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..75a894a
--- /dev/null
+++ b/.prettierrc
@@ -0,0 +1,5 @@
+{
+ "semi": false,
+ "singleQuote": true,
+ "printWidth": 100
+}
diff --git a/README.md b/README.md
index 784f6a7..748883b 100644
--- a/README.md
+++ b/README.md
@@ -4,18 +4,23 @@ A simple theme toggle for Next.js 13+ that allows switching between light and da
```html
+
+
```
You can then [use different CSS selectors to create styles for dark/light themes](https://www.designcise.com/web/tutorial/how-to-create-non-flickering-dark-or-light-mode-toggle-in-next-js-using-localstorage#adding-the-ability-to-switch-themes).
-## Goals
+## Features
-- Provide an easy way of toggling between light and dark themes
-- Auto-switch theme on page load based on system settings
-- Avoid flicker on page load
-- Have no unnecessary bloat
-- Have very minimal configuration
-- Be simple and intuitive
+- Easy implementation with just _two_ lines of code
+- No flicker on page load
+- Toggle between `light`, `dark` and `auto` modes
+- Automatically choose color based on `prefers-color-scheme` when in "`auto`" mode
+- Update color when `prefers-color-scheme` changes in `auto` mode
+- Switch to opposite color when toggling from "`auto`"
+- Data is stored in `localStorage`
+- No unnecessary bloat
+- Well-tested
## Installation
@@ -46,13 +51,11 @@ At a bare minimum you need to do the following:
```jsx
// app/layout.js
-import { cookies } from 'next/headers';
-import { Html, ThemeProvider } from '@designcise/next-theme-toggle';
-import { getColors } from '@designcise/next-theme-toggle/server';
+import { ThemeProvider } from '@designcise/next-theme-toggle';
+import { themes } from '@designcise/next-theme-toggle/server';
// 1: specify key for storage
-const THEME_STORAGE_KEY = 'theme-preference';
-const color = getColors();
+const THEME_STORAGE_KEY = 'theme-preference'
export default async function RootLayout() {
// 2: wrap components with `ThemeProvider` to pass theme props down to all components
@@ -60,7 +63,7 @@ export default async function RootLayout() {
return (
-
+
{children}
@@ -77,35 +80,31 @@ With this setup, the `ThemeProvider` component will automatically inject an inli
// components/ToggleThemeButton/index.jsx
'use client'
-import React, { useContext } from 'react';
-import { useTheme } from '@designcise/next-theme-toggle';
+import React, { useContext } from 'react'
+import { useTheme } from '@designcise/next-theme-toggle'
export default function ToggleThemeButton() {
- const { toggleTheme } = useTheme();
+ const { toggleTheme } = useTheme()
- return (
- Toggle Theme
- )
+ return Toggle Theme
}
```
-You can also do this manually by using `theme`, `color`, and `setTheme()`, for example, like so:
+You can also do this manually by using `theme`, `themes`, `colors` and `setTheme()`, for example, like so:
```jsx
// components/ToggleThemeButton/index.jsx
'use client'
-import React, { useContext } from 'react';
-import { useTheme } from '@designcise/next-theme-toggle';
+import React, { useContext } from 'react'
+import { useTheme } from '@designcise/next-theme-toggle'
export default function ToggleThemeButton() {
- const { theme, color, setTheme } = useTheme();
+ const { theme, themes, colors, setTheme } = useTheme()
return (
- setTheme(theme === color.dark ? color.light : color.dark)}
- >
- Toggle Theme
+ setTheme(theme === themes.dark ? colors.light : colors.dark)}>
+ Toggle Theme
)
}
@@ -115,7 +114,7 @@ export default function ToggleThemeButton() {
```jsx
// app/page.js
-import ToggleThemeButton from '@/components/ToggleThemeButton';
+import ToggleThemeButton from '@/components/ToggleThemeButton'
export default async function Home() {
return (
@@ -177,44 +176,47 @@ That's it! You should have light/dark theme toggle in your Next.js application.
You can pass the following props to `ThemeProvider`:
-| Prop | Type | Description |
-|----------------|:--------------------------------------------:|:------------------------------------------------------------------:|
-| `children` | `React.ReactChild`|`React.ReactChild[]` | Components to which the theme is passed down to via context. |
-| `storageKey` | String | Name of the key used for storage. |
-| `defaultTheme` | String | Default theme (`'light'` or `'dark'`) to use on initial page load. |
+| Prop | Type | Description |
+|----------------|:--------------------------------------------:|:--------------------------------------------------------------------------------------:|
+| `children` | `React.ReactChild`|`React.ReactChild[]` | Components to which the theme is passed down to via context. |
+| `storageKey` | String | Name of the key used for storage. |
+| `defaultTheme` | String | Default theme (`'light'`, `'dark'` or `auto`) to use on page load. Defaults to `auto`. |
### `useTheme()`
The `useTheme()` hook does not take any params; it returns the following:
-| Return Value | Type | Description |
-|---------------|:--------:|:-------------------------------------------------------------------:|
-| `theme` | String | The active theme (`'light'` or `'dark'`). |
-| `color` | Object | Color keys (`'light'` or `'dark'`) to compare active theme against. |
-| `setTheme` | Function | Setter to set new theme. |
-| `toggleTheme` | Function | Toggles the theme between `light` and `dark`. |
+| Return Value | Type | Description |
+|---------------|:--------:|:--------------------------------------------------------------------------------------------------------------------------------------------------------:|
+| `theme` | String | The active theme (`'light'`, `'dark'` or `'auto'`). |
+| `themes` | Object | Allowed themes (`{ light: 'light', dark: 'dark', auto: 'auto' }`). |
+| `color` | String | The active color (`light` or `dark`). |
+| `colors` | Object | Allowed colors (`{ light: 'light', dark: 'dark', auto: 'dark'|'light' }`). `colors.auto` returns `dark` or `light` based on `prefers-color-scheme`. |
+| `setTheme` | Function | Setter to set new theme. |
+| `toggleTheme` | Function | Toggles the theme between `light` and `dark`. When toggling from `auto`, the opposite color to active color is automatically chosen. |
-### `getColors()`
+### `themes`
-Returns an object, with the following:
+An object, with the following properties:
-| Property | Type | Value | Description |
-|----------|:------:|:---------:|:----------------------------------------:|
-| `light` | String | `'light'` | Color value used for light theme. |
-| `theme` | String | `'dark'`. | Color value used for dark theme. |
+| Property | Type | Value | Description |
+|----------|:------:|:---------:|:------------------------------------------------------------:|
+| `light` | String | `'light'` | Color value used for "light" theme. |
+| `dark` | String | `'dark'`. | Color value used for "dark" theme. |
+| `auto` | String | `'auto'`. | Auto-determine color scheme based on `prefers-color-scheme`. |
-> **NOTE**: The `getColors()` function can be used in both, client components and server components.
+> **NOTE**: The `themes` object can be used in both, client components and server components.
-For server components you can import `getColors()` like so:
+For server components you can import `themes` like so:
```jsx
-import { getColors } from '@designcise/next-theme-toggle/server';
+import { themes } from '@designcise/next-theme-toggle/server'
```
For client components, you can import it like so:
```jsx
-import { getColors } from '@designcise/next-theme-toggle';
+import { themes } from '@designcise/next-theme-toggle'
```
## Testing
@@ -237,8 +239,8 @@ $ yarn test
### Reporting
-* File issues at https://github.com/designcise/next-theme-toggle/issues
-* Issue patches to https://github.com/designcise/next-theme-toggle/pulls
+- File issues at https://github.com/designcise/next-theme-toggle/issues
+- Issue patches to https://github.com/designcise/next-theme-toggle/pulls
### Troubleshooting Common Issues
@@ -270,7 +272,7 @@ To fix this, you can add the folder where your CSS or SASS file is located. For
#### `Warning: Extra attributes from the server: class,style` in Console
-This warning _only_ shows on dev build and _not_ in the production build. This happens because the injected script adds _additional_ `class` and `style` attributes to the `html` element which _do not_ originally exist on the server-side generated page, leading to a mismatch in the server-side and client-side rendered page.
+You can safely ignore this warning as it _only_ shows on dev build and _not_ in the production build. This happens because the injected inline script adds _additional_ `class` and `style` attributes to the `html` element which _do not_ originally exist on the server-side generated page, leading to a _mismatch_ in the server-side and client-side rendered page.
## Contributing
@@ -282,5 +284,5 @@ https://github.com/designcise/next-theme-toggle/blob/main/LICENSE.md
## Resources
-- [https://www.designcise.com/web/tutorial/how-to-create-non-flickering-dark-or-light-mode-toggle-in-next-js-using-localstorage](https://www.designcise.com/web/tutorial/how-to-create-non-flickering-dark-or-light-mode-toggle-in-next-js-using-localstorage).
-
+- [How to Create Non-Flickering Dark/Light Mode Toggle in Next.js Using `localStorage`?](https://www.designcise.com/web/tutorial/how-to-create-non-flickering-dark-or-light-mode-toggle-in-next-js-using-localstorage)
+- [How to Create Non-Flickering Dark/Light Mode Toggle in Next.js Using Cookies?](https://www.designcise.com/web/tutorial/how-to-create-non-flickering-dark-or-light-mode-toggle-in-next-js-using-cookies).
diff --git a/__tests__/ThemeProvider.test.jsx b/__tests__/ThemeProvider.test.jsx
index 910ff26..275bf24 100644
--- a/__tests__/ThemeProvider.test.jsx
+++ b/__tests__/ThemeProvider.test.jsx
@@ -1,136 +1,242 @@
-import React from 'react';
-import { render, screen, fireEvent } from '@testing-library/react';
-import { ThemeProvider } from '../src/client';
-import { mockDeviceStorage, mockPreferredColorScheme } from './assets/device.mock';
-import { read, write, clear } from '../src/adapter/storage.adapter';
-import ThemeAutoToggle from './assets/ThemeAutoToggle';
-import ThemeManualToggle from './assets/ThemeManualToggle';
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { ThemeProvider } from '../src/client'
+import { mockLocalStorage, mockMatchMedia, mockPreferredColorScheme } from './mocks/device.mock'
+import { read, write, clear } from '../src/adapter/storage.adapter'
+import ThemeAutoToggle from './assets/ThemeAutoToggle'
+import ThemeManualToggle from './assets/ThemeManualToggle'
+import ThemeSwitcher from './assets/ThemeSwitcher'
+
+beforeAll(() => {
+ mockLocalStorage()
+ mockMatchMedia()
+})
beforeEach(() => {
- mockDeviceStorage();
- clear();
- document.documentElement.style.colorScheme = ''
- document.documentElement.removeAttribute('class');
-});
+ clear()
+ document.documentElement.style.colorScheme = ''
+ document.documentElement.removeAttribute('class')
+})
describe('provider', () => {
- test.each([
- 'light',
- 'dark',
- ])('should use the `defaultTheme` when nothing is stored in `localStorage`', (theme) => {
- const storageKey = 'test';
-
- render(
-
-
-
- );
-
- expect(read(storageKey)).toEqual(theme);
- expect(document.documentElement.classList[0]).toBe(theme);
- expect(document.documentElement.style.colorScheme).toBe(theme);
- });
-
- test.each([
- 'light',
- 'dark',
- ])('should set `color-scheme` and `class` to "%s" theme according to saved preference', (theme) => {
- const storageKey = 'test';
- write(storageKey, theme);
-
- render(
-
-
-
- );
-
- expect(document.documentElement.classList[0]).toBe(theme);
- expect(document.documentElement.style.colorScheme).toBe(theme);
- });
-
- test.each([
- 'light',
- 'dark',
- ])('should set resolve to system resolved theme "%s"', (theme) => {
- const storageKey = 'sys-resolved-theme';
- mockPreferredColorScheme(theme);
-
- render(
-
-
-
- );
-
- expect(read(storageKey)).toEqual(theme);
- expect(document.documentElement.classList[0]).toBe(theme);
- expect(document.documentElement.style.colorScheme).toBe(theme);
- });
-
- test.each([
- ['light', 'dark'],
- ['dark', 'light'],
- ])('should ignore nested `ThemeProvider`', (expectedTheme, nestedTheme) => {
- const storageKey = 'test';
-
- render(
-
-
-
-
-
- );
-
- expect(document.documentElement.classList[0]).toBe(expectedTheme);
- });
-
- test.each([
- ['light', 'dark'],
- ['dark', 'light'],
- ])('should update value in storage when toggling from "%s" to "%s" theme', (themeFrom, themeTo) => {
- const storageKey = 'test';
-
- render(
-
-
-
- );
-
- expect(read(storageKey)).toEqual(themeFrom);
-
- fireEvent.click(screen.getByText(/toggle theme/i));
-
- expect(read(storageKey)).toEqual(themeTo);
- });
-
- test.each([
- ['light', 'dark'],
- ['dark', 'light'],
- ])('should update value in storage when manually setting theme from "%s" to "%s"', (themeFrom, themeTo) => {
- const storageKey = 'test';
-
- render(
-
-
-
- );
-
- expect(read(storageKey)).toEqual(themeFrom);
-
- fireEvent.click(screen.getByText(/toggle theme/i));
-
- expect(read(storageKey)).toEqual(themeTo);
- });
-
- test('should set storage key according to the specified `storageKey`', () => {
- const storageKey = 'theme-test';
- const expectedTheme = 'light';
-
- render(
-
-
-
- );
-
- expect(read(storageKey)).toEqual(expectedTheme);
- });
-});
+ test('should set storage key according to the specified value', () => {
+ const storageKey = 'theme-test'
+ const expectedTheme = 'light'
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual(expectedTheme)
+ })
+
+ test.each(['light', 'dark'])(
+ 'should use the `defaultTheme` when nothing is stored in `localStorage`',
+ (theme) => {
+ const storageKey = 'test'
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual(theme)
+ expect(document.documentElement.classList[0]).toBe(theme)
+ expect(document.documentElement.style.colorScheme).toBe(theme)
+ },
+ )
+
+ test.each(['light', 'dark'])(
+ 'should auto-determine theme color when nothing is stored in `localStorage` and `defaultTheme` is set to "auto"',
+ (color) => {
+ const storageKey = 'test'
+ mockPreferredColorScheme(color)
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual('auto')
+ expect(document.documentElement.classList[0]).toBe(color)
+ expect(document.documentElement.style.colorScheme).toBe(color)
+ },
+ )
+
+ test.each(['light', 'dark'])(
+ 'should set `color-scheme` and `class` to "%s" theme color according to saved theme preference',
+ (color) => {
+ const storageKey = 'test'
+ write(storageKey, color)
+
+ render(
+
+
+ ,
+ )
+
+ expect(document.documentElement.classList[0]).toBe(color)
+ expect(document.documentElement.style.colorScheme).toBe(color)
+ },
+ )
+
+ test.each(['light', 'dark', 'auto'])(
+ 'should use system resolved "%s" color and "auto" theme when no `defaultTheme` is provided and nothing is stored in `localStorage`',
+ (color) => {
+ const storageKey = 'sys-resolved-theme'
+ const prefColor = color === 'auto' ? 'dark' : color
+
+ mockPreferredColorScheme(prefColor)
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual('auto')
+ expect(document.documentElement.classList[0]).toBe(prefColor)
+ expect(document.documentElement.style.colorScheme).toBe(prefColor)
+ },
+ )
+
+ test.each(['light', 'dark'])(
+ 'should set theme color automatically based on user system preference',
+ (sysPrefColor) => {
+ const storageKey = 'sys-resolved-theme'
+ mockPreferredColorScheme(sysPrefColor)
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual('auto')
+ expect(document.documentElement.classList[0]).toBe(sysPrefColor)
+ expect(document.documentElement.style.colorScheme).toBe(sysPrefColor)
+ },
+ )
+
+ test.each([
+ ['light', 'dark'],
+ ['dark', 'light'],
+ ])('should ignore nested `ThemeProvider`', (expectedTheme, nestedTheme) => {
+ const storageKey = 'test'
+
+ render(
+
+
+
+
+ ,
+ )
+
+ expect(document.documentElement.classList[0]).toBe(expectedTheme)
+ })
+
+ test.each([
+ ['light', 'dark'],
+ ['dark', 'light'],
+ ])(
+ 'should update value in storage when toggling from "%s" to "%s" theme',
+ (themeFrom, themeTo) => {
+ const storageKey = 'test'
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual(themeFrom)
+
+ fireEvent.click(screen.getByText(/toggle theme/i))
+
+ expect(read(storageKey)).toEqual(themeTo)
+ },
+ )
+
+ test.each([
+ ['light', 'dark'],
+ ['dark', 'light'],
+ ])(
+ 'should update value in storage when manually setting theme from "%s" to "%s"',
+ (themeFrom, themeTo) => {
+ const storageKey = 'test'
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual(themeFrom)
+
+ fireEvent.click(screen.getByText(/toggle theme/i))
+
+ expect(read(storageKey)).toEqual(themeTo)
+ },
+ )
+
+ test.each(['light', 'dark'])('should switch from "auto" to "%s"', (theme) => {
+ const storageKey = 'sys-resolved-theme'
+ const oppositeTheme = theme === 'dark' ? 'light' : 'dark'
+ mockPreferredColorScheme(oppositeTheme)
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual('auto')
+ expect(document.documentElement.classList[0]).toBe(oppositeTheme)
+ expect(document.documentElement.style.colorScheme).toBe(oppositeTheme)
+
+ fireEvent.click(screen.getByText(new RegExp(`${theme} theme`, 'i')))
+
+ expect(read(storageKey)).toEqual(theme)
+ expect(document.documentElement.classList[0]).toBe(theme)
+ expect(document.documentElement.style.colorScheme).toBe(theme)
+ })
+
+ test.each(['light', 'dark'])('should switch from "%s" to "auto"', (theme) => {
+ const storageKey = 'sys-resolved-theme'
+ const oppositeTheme = theme === 'dark' ? 'light' : 'dark'
+ mockPreferredColorScheme(oppositeTheme)
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual(theme)
+ expect(document.documentElement.classList[0]).toBe(theme)
+ expect(document.documentElement.style.colorScheme).toBe(theme)
+
+ fireEvent.click(screen.getByText('Auto Theme'))
+
+ expect(read(storageKey)).toEqual('auto')
+ expect(document.documentElement.classList[0]).toBe(oppositeTheme)
+ expect(document.documentElement.style.colorScheme).toBe(oppositeTheme)
+ })
+
+ test('should not set `colorScheme` and class name to "auto"', () => {
+ const storageKey = 'sys-resolved-theme'
+
+ render(
+
+
+ ,
+ )
+
+ expect(document.documentElement.classList[0]).not.toBe('auto')
+ expect(document.documentElement.style.colorScheme).not.toBe('auto')
+ })
+})
diff --git a/__tests__/assets/ThemeAutoColor.jsx b/__tests__/assets/ThemeAutoColor.jsx
new file mode 100644
index 0000000..c8cb693
--- /dev/null
+++ b/__tests__/assets/ThemeAutoColor.jsx
@@ -0,0 +1,13 @@
+import React from 'react'
+import { useTheme } from '../../src/client'
+
+export default function ThemeAutoColor() {
+ const { theme, colors } = useTheme()
+
+ return (
+ <>
+ Active Theme: {theme}
+ Auto-determined Color: {colors.auto}
+ >
+ )
+}
diff --git a/__tests__/assets/ThemeAutoToggle.jsx b/__tests__/assets/ThemeAutoToggle.jsx
index 6bae563..7501357 100644
--- a/__tests__/assets/ThemeAutoToggle.jsx
+++ b/__tests__/assets/ThemeAutoToggle.jsx
@@ -1,12 +1,10 @@
'use client'
-import React from 'react';
-import { useTheme } from '../../src/client';
+import React from 'react'
+import { useTheme } from '../../src/client'
export default function ToggleThemeButton() {
- const { toggleTheme } = useTheme();
+ const { toggleTheme } = useTheme()
- return (
- Toggle Theme
- )
+ return Toggle Theme
}
diff --git a/__tests__/assets/ThemeManualToggle.jsx b/__tests__/assets/ThemeManualToggle.jsx
index 1e66e7d..126cf4c 100644
--- a/__tests__/assets/ThemeManualToggle.jsx
+++ b/__tests__/assets/ThemeManualToggle.jsx
@@ -1,16 +1,14 @@
'use client'
-import React from 'react';
-import { useTheme } from '../../src/client';
+import React from 'react'
+import { useTheme } from '../../src/client'
export default function ToggleThemeButton() {
- const { theme, color, setTheme } = useTheme();
+ const { theme, themes, colors, setTheme } = useTheme()
- return (
- setTheme(theme === color.dark ? color.light : color.dark)}
- >
- Toggle Theme
-
- )
+ return (
+ setTheme(theme === themes.dark ? colors.light : colors.dark)}>
+ Toggle Theme
+
+ )
}
diff --git a/__tests__/assets/ThemeSwitcher.jsx b/__tests__/assets/ThemeSwitcher.jsx
index 255abcc..38d52dd 100644
--- a/__tests__/assets/ThemeSwitcher.jsx
+++ b/__tests__/assets/ThemeSwitcher.jsx
@@ -1,14 +1,16 @@
-import React from 'react';
-import { useTheme } from '../../src/client';
+import React from 'react'
+import { useTheme } from '../../src/client'
export default function ThemeSwitcher() {
- const { theme, color, setTheme } = useTheme();
+ const { theme, themes, color, setTheme } = useTheme()
- return (
- <>
- Active Theme: {theme}
- setTheme(color.light)}>Light Theme
- setTheme(color.dark)}>Dark Theme
- >
- )
+ return (
+ <>
+ Active Theme: {theme}
+ Active Color: {color}
+ setTheme(themes.light)}>Light Theme
+ setTheme(themes.dark)}>Dark Theme
+ setTheme(themes.auto)}>Auto Theme
+ >
+ )
}
diff --git a/__tests__/assets/device.mock.js b/__tests__/assets/device.mock.js
deleted file mode 100644
index 76ee0cf..0000000
--- a/__tests__/assets/device.mock.js
+++ /dev/null
@@ -1,38 +0,0 @@
-export function mockPreferredColorScheme(theme) {
- Object.defineProperty(window, 'matchMedia', {
- writable: true,
- value: jest.fn().mockImplementation(query => ({
- matches: theme === 'dark',
- media: query,
- onchange: null,
- addEventListener: jest.fn(),
- removeEventListener: jest.fn(),
- dispatchEvent: jest.fn(),
- }))
- })
-}
-
-export function mockDeviceStorage() {
- const localStorageMock = (function() {
- let store = {}
-
- return {
- getItem: function(key) {
- return store[key] || null;
- },
- setItem: function(key, value) {
- store[key] = value.toString();
- },
- removeItem: function(key) {
- delete store[key];
- },
- clear: function() {
- store = {};
- },
- };
- })();
-
- Object.defineProperty(window, 'localStorage', {
- value: localStorageMock,
- });
-}
diff --git a/__tests__/mocks/device.mock.js b/__tests__/mocks/device.mock.js
new file mode 100644
index 0000000..a37186d
--- /dev/null
+++ b/__tests__/mocks/device.mock.js
@@ -0,0 +1,55 @@
+export function mockMatchMedia() {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation((query) => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // Deprecated
+ removeListener: jest.fn(), // Deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+ })
+}
+
+export function mockPreferredColorScheme(theme, options = {}) {
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation((query) => ({
+ matches: theme === 'dark',
+ media: query,
+ onchange: null,
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ ...options,
+ })),
+ })
+}
+
+export function mockLocalStorage() {
+ const localStorageMock = (function () {
+ let store = {}
+
+ return {
+ getItem: function (key) {
+ return store[key] || null
+ },
+ setItem: function (key, value) {
+ store[key] = value.toString()
+ },
+ removeItem: function (key) {
+ delete store[key]
+ },
+ clear: function () {
+ store = {}
+ },
+ }
+ })()
+
+ Object.defineProperty(window, 'localStorage', {
+ value: localStorageMock,
+ })
+}
diff --git a/__tests__/mocks/localStorage.mock.js b/__tests__/mocks/localStorage.mock.js
new file mode 100644
index 0000000..ce8f2bf
--- /dev/null
+++ b/__tests__/mocks/localStorage.mock.js
@@ -0,0 +1,3 @@
+import { mockLocalStorage } from './device.mock'
+
+mockLocalStorage()
diff --git a/__tests__/mocks/matchMedia.mock.js b/__tests__/mocks/matchMedia.mock.js
new file mode 100644
index 0000000..051c6b9
--- /dev/null
+++ b/__tests__/mocks/matchMedia.mock.js
@@ -0,0 +1,3 @@
+import { mockMatchMedia } from './device.mock'
+
+mockMatchMedia()
diff --git a/__tests__/theme.helper.test.js b/__tests__/theme.helper.test.js
new file mode 100644
index 0000000..1713274
--- /dev/null
+++ b/__tests__/theme.helper.test.js
@@ -0,0 +1,68 @@
+import './mocks/matchMedia.mock'
+import './mocks/localStorage.mock'
+import { mockPreferredColorScheme } from './mocks/device.mock'
+import { write, clear } from '../src/adapter/storage.adapter'
+import { colors, flipThemeByColor, getTheme, getColorByTheme } from '../src/helper/theme.helper'
+
+const storageKey = 'theme-pref'
+
+beforeEach(() => {
+ clear()
+})
+
+describe('getTheme()', () => {
+ test.each([
+ [undefined, undefined, 'auto'],
+ [undefined, 'dark', 'dark'],
+ [undefined, 'light', 'light'],
+ [undefined, 'auto', 'auto'],
+ ['dark', undefined, 'dark'],
+ ['light', undefined, 'light'],
+ ['auto', undefined, 'auto'],
+ ['dark', 'light', 'dark'],
+ ['light', 'dark', 'light'],
+ ['auto', 'light', 'auto'],
+ ])(
+ 'should get the theme from storage or the fallback',
+ (storedTheme, defaultTheme, expectedTheme) => {
+ if (storedTheme) {
+ write(storageKey, storedTheme)
+ }
+
+ expect(getTheme(storageKey, defaultTheme)).toEqual(expectedTheme)
+ },
+ )
+})
+
+describe('getColorByTheme()', () => {
+ test.each([
+ ['dark', colors.dark],
+ ['light', colors.light],
+ ['auto', colors.dark],
+ ])('should get the color based on the theme', (theme, expectedColor) => {
+ if (theme === 'auto') {
+ mockPreferredColorScheme(expectedColor)
+ }
+
+ expect(getColorByTheme(theme)).toEqual(expectedColor)
+ })
+})
+
+describe('flipThemeByColor()', () => {
+ test.each([
+ [colors.dark, 'light'],
+ [colors.light, 'dark'],
+ ])('should get the opposite theme based on the color', (color, expectedTheme) => {
+ expect(flipThemeByColor(color)).toEqual(expectedTheme)
+ })
+})
+
+describe('colors', () => {
+ test.each(['dark', 'light'])(
+ 'should automatically determine the color based on the system preferred color',
+ (prefColor) => {
+ mockPreferredColorScheme(prefColor)
+ expect(colors.auto).toEqual(prefColor)
+ },
+ )
+})
diff --git a/__tests__/useTheme.test.jsx b/__tests__/useTheme.test.jsx
index ac72ce0..a1afdb1 100644
--- a/__tests__/useTheme.test.jsx
+++ b/__tests__/useTheme.test.jsx
@@ -1,101 +1,170 @@
-import React from 'react';
-import { render, screen, fireEvent } from '@testing-library/react';
-import { ThemeProvider } from '../src/client';
-import { mockDeviceStorage, mockPreferredColorScheme } from './assets/device.mock';
-import { clear, read } from '../src/adapter/storage.adapter';
-import ThemeAutoToggle from './assets/ThemeAutoToggle';
-import ThemeManualToggle from './assets/ThemeManualToggle';
-import ThemeSwitcher from './assets/ThemeSwitcher';
-import '@testing-library/jest-dom';
+import React from 'react'
+import { render, screen, fireEvent } from '@testing-library/react'
+import { ThemeProvider } from '../src/client'
+import { mockLocalStorage, mockMatchMedia, mockPreferredColorScheme } from './mocks/device.mock'
+import { clear, read } from '../src/adapter/storage.adapter'
+import ThemeAutoToggle from './assets/ThemeAutoToggle'
+import ThemeManualToggle from './assets/ThemeManualToggle'
+import ThemeSwitcher from './assets/ThemeSwitcher'
+import '@testing-library/jest-dom'
+
+beforeAll(() => {
+ mockLocalStorage()
+ mockMatchMedia()
+})
beforeEach(() => {
- mockDeviceStorage();
- clear();
- document.documentElement.style.colorScheme = ''
- document.documentElement.removeAttribute('class');
-});
+ clear()
+ document.documentElement.style.colorScheme = ''
+ document.documentElement.removeAttribute('class')
+})
describe('useTheme', () => {
- test.each([
- ['light', 'dark'],
- ['dark', 'light'],
- ])('should toggle "%s" theme to "%s"', (themeFrom, themeTo) => {
- const storageKey = 'test';
-
- render(
-
-
-
- );
-
- expect(document.documentElement.classList[0]).toBe(themeFrom);
- expect(document.documentElement.style.colorScheme).toBe(themeFrom);
-
- fireEvent.click(screen.getByText(/toggle theme/i));
-
- expect(document.documentElement.classList[0]).toBe(themeTo);
- expect(document.documentElement.style.colorScheme).toBe(themeTo);
- });
-
- test.each([
- ['light', 'dark'],
- ['dark', 'light'],
- ])('should toggle system resolved "%s" theme to "%s"', (themeFrom, themeTo) => {
- const storageKey = 'sys-resolved-theme';
- mockPreferredColorScheme(themeFrom);
-
- render(
-
-
-
- );
-
- expect(document.documentElement.classList[0]).toBe(themeFrom);
- expect(document.documentElement.style.colorScheme).toBe(themeFrom);
-
- fireEvent.click(screen.getByText(/toggle theme/i));
-
- expect(document.documentElement.classList[0]).toBe(themeTo);
- expect(document.documentElement.style.colorScheme).toBe(themeTo);
- });
-
- test.each([
- ['light', 'dark'],
- ['dark', 'light'],
- ])('should get right values to manually set theme from "%s" to "%s"', (themeFrom, themeTo) => {
- const storageKey = 'test';
-
- render(
-
-
-
- );
-
- expect(document.documentElement.classList[0]).toBe(themeFrom);
- expect(document.documentElement.style.colorScheme).toBe(themeFrom);
-
- fireEvent.click(screen.getByText(/toggle theme/i));
-
- expect(document.documentElement.classList[0]).toBe(themeTo);
- expect(document.documentElement.style.colorScheme).toBe(themeTo);
- });
-
- test.each([
- 'light',
- 'dark',
- ])('should get "%s" as the active theme', (theme) => {
- const storageKey = 'user-theme';
- const oppositeTheme = (theme === 'light') ? 'dark' : 'light';
-
- render(
-
-
-
- );
-
- fireEvent.click(screen.getByText(new RegExp(`${theme} theme`, 'i')));
-
- expect(screen.getByText(`Active Theme: ${theme}`)).toBeInTheDocument();
- expect(read(storageKey)).toEqual(theme);
- });
-});
+ test.each([
+ ['light', 'dark'],
+ ['dark', 'light'],
+ ])('should toggle "%s" theme to "%s"', (themeFrom, themeTo) => {
+ const storageKey = 'test'
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual(themeFrom)
+ expect(document.documentElement.classList[0]).toBe(themeFrom)
+ expect(document.documentElement.style.colorScheme).toBe(themeFrom)
+
+ fireEvent.click(screen.getByText(/toggle theme/i))
+
+ expect(read(storageKey)).toEqual(themeTo)
+ expect(document.documentElement.classList[0]).toBe(themeTo)
+ expect(document.documentElement.style.colorScheme).toBe(themeTo)
+ })
+
+ test.each([
+ ['light', 'dark'],
+ ['dark', 'light'],
+ ])(
+ 'should toggle from system resolved "%s" theme to opposite theme "%s" when using `toggle` function',
+ (themeFrom, themeTo) => {
+ const storageKey = 'sys-resolved-theme'
+ mockPreferredColorScheme(themeFrom)
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual('auto')
+ expect(document.documentElement.classList[0]).toBe(themeFrom)
+ expect(document.documentElement.style.colorScheme).toBe(themeFrom)
+
+ fireEvent.click(screen.getByText(/toggle theme/i))
+
+ expect(read(storageKey)).toEqual(themeTo)
+ expect(document.documentElement.classList[0]).toBe(themeTo)
+ expect(document.documentElement.style.colorScheme).toBe(themeTo)
+ },
+ )
+
+ test.each([
+ ['light', 'dark'],
+ ['dark', 'light'],
+ ])('should get right values to manually set theme from "%s" to "%s"', (themeFrom, themeTo) => {
+ const storageKey = 'test'
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual(themeFrom)
+
+ fireEvent.click(screen.getByText(/toggle theme/i))
+
+ expect(read(storageKey)).toEqual(themeTo)
+ })
+
+ test.each(['light', 'dark'])('should get "%s" as the active `theme` and `color`', (theme) => {
+ const storageKey = 'user-theme'
+ const oppositeTheme = theme === 'light' ? 'dark' : 'light'
+
+ render(
+
+
+ ,
+ )
+
+ fireEvent.click(screen.getByText(new RegExp(`${theme} theme`, 'i')))
+
+ expect(screen.getByText(`Active Theme: ${theme}`)).toBeInTheDocument()
+ expect(screen.getByText(`Active Color: ${theme}`)).toBeInTheDocument()
+ expect(read(storageKey)).toEqual(theme)
+ })
+
+ test.each(['light', 'dark'])(
+ 'should get "%s" as the active `color` when theme is set to "auto"',
+ (colorScheme) => {
+ const storageKey = 'user-theme'
+ mockPreferredColorScheme(colorScheme)
+
+ render(
+
+
+ ,
+ )
+
+ expect(screen.getByText('Active Theme: auto')).toBeInTheDocument()
+ expect(screen.getByText(`Active Color: ${colorScheme}`)).toBeInTheDocument()
+ },
+ )
+
+ test.each([
+ ['light', 'dark'],
+ ['dark', 'light'],
+ ])(
+ 'should switch to opposite color of "%s" when toggling from "auto"',
+ (sysPrefColor, switchToTheme) => {
+ const storageKey = 'sys-resolved-theme'
+ mockPreferredColorScheme(sysPrefColor)
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual('auto')
+ expect(document.documentElement.classList[0]).toBe(sysPrefColor)
+ expect(document.documentElement.style.colorScheme).toBe(sysPrefColor)
+
+ fireEvent.click(screen.getByText(/toggle theme/i))
+
+ expect(read(storageKey)).toEqual(switchToTheme)
+ expect(document.documentElement.classList[0]).toBe(switchToTheme)
+ expect(document.documentElement.style.colorScheme).toBe(switchToTheme)
+ },
+ )
+
+ test.each(['light', 'dark'])(
+ 'should auto-determine color to be "%s" via `colors.auto`',
+ (prefColor) => {
+ const storageKey = 'sys-resolved-theme'
+ mockPreferredColorScheme(prefColor)
+
+ render(
+
+
+ ,
+ )
+
+ expect(read(storageKey)).toEqual('auto')
+ expect(document.documentElement.classList[0]).toBe(prefColor)
+ expect(document.documentElement.style.colorScheme).toBe(prefColor)
+ },
+ )
+})
diff --git a/package-lock.json b/package-lock.json
index 3ecad1c..ba9e1f1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"babel-jest": "^29.7.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
+ "prettier": "3.1.0",
"rollup": "^4.6.0",
"rollup-plugin-swc3": "^0.10.4",
"rollup-swc-preserve-directives": "^0.7.0"
@@ -6721,6 +6722,21 @@
"node": ">=8"
}
},
+ "node_modules/prettier": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz",
+ "integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==",
+ "dev": true,
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
@@ -12587,6 +12603,12 @@
"find-up": "^4.0.0"
}
},
+ "prettier": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.0.tgz",
+ "integrity": "sha512-TQLvXjq5IAibjh8EpBIkNKxO749UEWABoiIZehEPiY4GNpVdhaFKqSTu+QrlU6D2dPAfubRmtJTi4K4YkQ5eXw==",
+ "dev": true
+ },
"pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
diff --git a/package.json b/package.json
index 415cbed..4294012 100644
--- a/package.json
+++ b/package.json
@@ -13,7 +13,9 @@
"scripts": {
"prepare": "rollup -c",
"start": "rollup -c -w",
- "test": "jest --silent=false"
+ "test": "jest --silent=false",
+ "lint": "prettier . --check",
+ "lint:fix": "prettier . --write"
},
"author": "Daniyal Hamid",
"license": "ISC",
@@ -30,13 +32,14 @@
"@babel/plugin-proposal-optional-chaining": "^7.21.0",
"@babel/preset-env": "^7.23.3",
"@babel/preset-react": "^7.23.3",
- "@testing-library/jest-dom": "^6.1.4",
"@rollup/plugin-commonjs": "^25.0.7",
"@rollup/plugin-node-resolve": "^15.2.3",
+ "@testing-library/jest-dom": "^6.1.4",
"@testing-library/react": "^14.1.2",
"babel-jest": "^29.7.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
+ "prettier": "3.1.0",
"rollup": "^4.6.0",
"rollup-plugin-swc3": "^0.10.4",
"rollup-swc-preserve-directives": "^0.7.0"
diff --git a/rollup.config.mjs b/rollup.config.mjs
index 1f2923e..ca29d4b 100644
--- a/rollup.config.mjs
+++ b/rollup.config.mjs
@@ -1,27 +1,29 @@
-import { swc } from 'rollup-plugin-swc3';
-import swcPreserveDirectives from 'rollup-swc-preserve-directives';
-import { nodeResolve } from '@rollup/plugin-node-resolve';
+import { swc } from 'rollup-plugin-swc3'
+import swcPreserveDirectives from 'rollup-swc-preserve-directives'
+import { nodeResolve } from '@rollup/plugin-node-resolve'
export default {
- input: {
- client: 'src/client.js',
- server: 'src/server.js'
+ input: {
+ client: 'src/client.js',
+ server: 'src/server.js',
+ },
+ output: [
+ {
+ dir: 'dist/',
+ entryFileNames: '[name].js',
+ format: 'esm',
+ exports: 'named',
+ sourcemap: false,
+ strict: false,
},
- output: [{
- dir: 'dist/',
- entryFileNames: '[name].js',
- format: 'esm',
- exports: 'named',
- sourcemap: false,
- strict: false,
- }],
- plugins: [
- swc(),
- swcPreserveDirectives(),
- nodeResolve({
- extensions: ['.js'],
- mainFields: ['exports', 'main'],
- }),
- ],
- external: ['react', 'react-dom'],
+ ],
+ plugins: [
+ swc(),
+ swcPreserveDirectives(),
+ nodeResolve({
+ extensions: ['.js'],
+ mainFields: ['exports', 'main'],
+ }),
+ ],
+ external: ['react', 'react-dom'],
}
diff --git a/src/adapter/storage.adapter.js b/src/adapter/storage.adapter.js
index cc1ed91..de66dc2 100644
--- a/src/adapter/storage.adapter.js
+++ b/src/adapter/storage.adapter.js
@@ -1,13 +1,13 @@
-export const read = (key) => localStorage.getItem(key);
+export const read = (key) => localStorage.getItem(key)
export const write = (key, value) => {
- localStorage.setItem(key, value);
+ localStorage.setItem(key, value)
}
export const erase = (key) => {
- localStorage.removeItem(key);
+ localStorage.removeItem(key)
}
export const clear = () => {
- localStorage.clear();
+ localStorage.clear()
}
diff --git a/src/client.js b/src/client.js
index d7f1fc6..2dd6218 100644
--- a/src/client.js
+++ b/src/client.js
@@ -1,4 +1,3 @@
-export { default as Html } from './component/Html.jsx';
-export { default as ThemeProvider } from './context/ThemeProvider.jsx';
-export { default as useTheme } from './hook/useTheme';
-export { getColors } from './helper/color.helper';
+export { default as ThemeProvider } from './context/ThemeProvider.jsx'
+export { default as useTheme } from './hook/useTheme'
+export { themes } from './helper/theme.helper'
diff --git a/src/component/AntiFlickerScript.jsx b/src/component/AntiFlickerScript.jsx
index 679a9ca..e4ae1c7 100644
--- a/src/component/AntiFlickerScript.jsx
+++ b/src/component/AntiFlickerScript.jsx
@@ -1,12 +1,22 @@
-import React, { memo } from 'react';
+import React, { memo } from 'react'
+import { themes, colors as palette, getColorByTheme } from '../helper/theme.helper'
-export default memo(function AntiFlickerScript({ storageKey, defaultTheme, color }) {
- const classList = Object.values(color).join("','");
- const preferredTheme = `localStorage.getItem('${storageKey}')`;
- const fallbackTheme = defaultTheme ? `'${defaultTheme}'` : `(window.matchMedia('(prefers-color-scheme: ${color.dark})').matches ? '${color.dark}' : '${color.light}')`;
- const script = '(function(root){'
- + `const theme=${preferredTheme}??${fallbackTheme};`
- + `root.classList.remove('${classList}');root.classList.add(theme);root.style.colorScheme=theme;`
- + `})(document.documentElement)`;
+export default memo(
+ function AntiFlickerScript({ storageKey, defaultTheme }) {
+ const { [themes.auto]: _, ...colors } = palette
+ const classList = Object.values(colors).join("','")
+ const preferredTheme = `localStorage.getItem('${storageKey}')`
+ const fallbackTheme =
+ defaultTheme && defaultTheme !== themes.auto
+ ? `'${getColorByTheme(defaultTheme)}'`
+ : `(window.matchMedia('(prefers-color-scheme: ${colors.dark})').matches?'${colors.dark}':'${colors.light}')`
+ const script =
+ '(function(root){' +
+ `const pref=${preferredTheme};` +
+ `const theme=(pref&&pref!=='${themes.auto}')?pref:${fallbackTheme};` +
+ `root.classList.remove('${classList}');root.classList.add(theme);root.style.colorScheme=theme;` +
+ `})(document.documentElement)`
return
-}, () => true);
+ },
+ () => true,
+)
diff --git a/src/component/Html.jsx b/src/component/Html.jsx
deleted file mode 100644
index 46958e2..0000000
--- a/src/component/Html.jsx
+++ /dev/null
@@ -1,8 +0,0 @@
-import React from 'react';
-
-export default function Html({ className, theme, style, children, ...rest }) {
- const classes = className ? `${className} ` : '';
- return (
- {children}
- );
-}
diff --git a/src/context/ThemeContext.js b/src/context/ThemeContext.js
index d93d6b7..9aaca26 100644
--- a/src/context/ThemeContext.js
+++ b/src/context/ThemeContext.js
@@ -1,8 +1,10 @@
-import { createContext } from 'react';
+import { createContext } from 'react'
export default createContext({
- theme: undefined,
- color: undefined,
- toggleTheme: () => {},
- setTheme: () => {},
-});
+ theme: undefined,
+ themes: undefined,
+ color: undefined,
+ colors: undefined,
+ toggleTheme: () => {},
+ setTheme: () => {},
+})
diff --git a/src/context/ThemeProvider.jsx b/src/context/ThemeProvider.jsx
index 4d4c361..2254957 100644
--- a/src/context/ThemeProvider.jsx
+++ b/src/context/ThemeProvider.jsx
@@ -1,32 +1,50 @@
'use client'
-import React, { useEffect, useState, useCallback } from 'react';
-import ThemeContext from './ThemeContext';
-import AntiFlickerScript from '../component/AntiFlickerScript';
-import { getPreference, setPreference } from '../helper/theme.helper';
-import { getColors } from '../helper/color.helper';
-
-const color = getColors();
-
-export default function ThemeProvider({
- children,
- storageKey,
- defaultTheme,
-}) {
- const [theme, setTheme] = useState(getPreference(storageKey, defaultTheme));
-
- useEffect(() => {
- setPreference(storageKey, theme);
- }, [storageKey, theme]);
-
- const toggleTheme = useCallback(() => {
- setTheme(theme === color.dark ? color.light : color.dark);
- }, [theme, setTheme]);
-
- return (
-
-
- {children}
-
- );
+import React, { useEffect, useState, useCallback } from 'react'
+import ThemeContext from './ThemeContext'
+import AntiFlickerScript from '../component/AntiFlickerScript'
+import {
+ themes,
+ colors,
+ flipThemeByColor,
+ getColorByTheme,
+ getTheme,
+ saveTheme,
+} from '../helper/theme.helper'
+
+export default function ThemeProvider({ children, storageKey, defaultTheme = themes.auto }) {
+ const [theme, setTheme] = useState(getTheme(storageKey, defaultTheme))
+ const [color, setColor] = useState(getColorByTheme(theme))
+
+ const updateTheme = useCallback(
+ (storageKey, theme) => {
+ setColor(getColorByTheme(theme))
+ saveTheme(storageKey, theme)
+ },
+ [storageKey, theme, setColor],
+ )
+
+ const toggleTheme = useCallback(() => setTheme(flipThemeByColor(color)), [color, setTheme])
+
+ useEffect(() => updateTheme(storageKey, theme), [storageKey, theme, updateTheme])
+
+ useEffect(() => {
+ if (typeof window === 'undefined' || theme !== themes.auto) {
+ return
+ }
+
+ const updateFn = () => updateTheme(storageKey, theme)
+
+ const mediaQuery = window.matchMedia(`(prefers-color-scheme: ${colors.dark})`)
+ mediaQuery.addEventListener('change', updateFn)
+
+ return () => mediaQuery.removeEventListener('change', updateFn)
+ }, [theme, setTheme])
+
+ return (
+
+
+ {children}
+
+ )
}
diff --git a/src/helper/color.helper.js b/src/helper/color.helper.js
deleted file mode 100644
index 4d64892..0000000
--- a/src/helper/color.helper.js
+++ /dev/null
@@ -1,4 +0,0 @@
-const THEME_DARK = 'dark'
-const THEME_LIGHT = 'light';
-
-export const getColors = () => ({ dark: THEME_DARK, light: THEME_LIGHT });
diff --git a/src/helper/env.helper.js b/src/helper/env.helper.js
new file mode 100644
index 0000000..61b864e
--- /dev/null
+++ b/src/helper/env.helper.js
@@ -0,0 +1 @@
+export const isServer = () => typeof window === 'undefined'
diff --git a/src/helper/theme.helper.js b/src/helper/theme.helper.js
index c1b99d0..a1268dd 100644
--- a/src/helper/theme.helper.js
+++ b/src/helper/theme.helper.js
@@ -1,28 +1,42 @@
-import { read, write, erase } from '../adapter/storage.adapter';
-import { getColors } from './color.helper';
+import { read, write, erase } from '../adapter/storage.adapter'
+import { isServer } from './env.helper'
-const color = getColors();
-const isServer = () => (typeof window === 'undefined');
+export const themes = { dark: 'dark', light: 'light', auto: 'auto' }
+export const colors = {
+ [themes.dark]: 'dark',
+ [themes.light]: 'light',
+ get [themes.auto]() {
+ return isServer()
+ ? undefined
+ : window.matchMedia(`(prefers-color-scheme: ${this[themes.dark]})`).matches
+ ? this[themes.dark]
+ : this[themes.light]
+ },
+}
+
+export const getTheme = (storageKey, defaultTheme) =>
+ isServer() ? undefined : read(storageKey) ?? defaultTheme ?? themes.auto
-const applyPreference = (theme) => {
- const root = document.firstElementChild;
- root.classList.remove(...Object.values(color));
- root.classList.add(theme);
- root.style.colorScheme = theme;
-};
+export const getColorByTheme = (theme) => colors[theme ?? themes.auto]
-export const getPreference = (storageKey, defaultPref) => {
- if (isServer()) {
- return;
- }
+const colorToTheme = {
+ [colors.dark]: themes.dark,
+ [colors.light]: themes.light,
+}
- return read(storageKey)
- ?? defaultPref
- ?? (window.matchMedia(`(prefers-color-scheme: ${color.dark})`).matches ? color.dark : color.light)
-};
+export const flipThemeByColor = (color) =>
+ colorToTheme[color] === themes.dark ? themes.light : themes.dark
+
+const applyTheme = (theme) => {
+ const color = getColorByTheme(theme)
+ const root = document.firstElementChild
+ root.classList.remove(...Object.values(colors))
+ root.classList.add(color)
+ root.style.colorScheme = color
+}
-export const setPreference = (storageKey, theme) => {
- erase(storageKey);
- write(storageKey, theme);
- applyPreference(theme);
+export const saveTheme = (storageKey, theme) => {
+ erase(storageKey)
+ write(storageKey, theme)
+ applyTheme(theme)
}
diff --git a/src/hook/useTheme.js b/src/hook/useTheme.js
index 19b7f4b..9cbc32c 100644
--- a/src/hook/useTheme.js
+++ b/src/hook/useTheme.js
@@ -1,8 +1,8 @@
'use client'
-import { useContext } from 'react';
-import ThemeContext from '../context/ThemeContext';
+import { useContext } from 'react'
+import ThemeContext from '../context/ThemeContext'
export default function useTheme() {
- return useContext(ThemeContext);
+ return useContext(ThemeContext)
}
diff --git a/src/server.js b/src/server.js
index b68ce64..d764606 100644
--- a/src/server.js
+++ b/src/server.js
@@ -1 +1 @@
-export { getColors } from './helper/color.helper';
+export { themes } from './helper/theme.helper'