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 ( - - ) + return } ``` -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 ( - ) } @@ -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 ( - - ) + return } 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 ( - - ) + return ( + + ) } 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}
- - - - ) + return ( + <> +
Active Theme: {theme}
+
Active Color: {color}
+ + + + + ) } 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