diff --git a/README.md b/README.md index 040cfbb..784f6a7 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,21 @@ # next-theme-toggle -This package is based on [https://www.designcise.com/web/tutorial/how-to-create-non-flickering-dark-or-light-mode-toggle-in-next-js](https://www.designcise.com/web/tutorial/how-to-create-non-flickering-dark-or-light-mode-toggle-in-next-js). +A simple theme toggle for Next.js 13+ that allows switching between light and dark themes. Using this package would result in the following `class` and `style` attributes added to the `` element: -## Goals +```html + +``` -The goal of the project is to: +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 - 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 - -## Expectations - -Result of using this package will be that the following are added to the `` element: - -```html - -``` - -After which you can [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#switching-theme). +- Be simple and intuitive ## Installation @@ -43,7 +38,7 @@ $ yarn add @designcise/next-theme-toggle ## Quickstart -> **NOTE**: Please note that this approach relies on using cookies on client and server side, and will, therefore, cause the route to be dynamically rendered as cookies rely on request time information. +> **NOTE**: Please note that this approach relies on using `localStorage` on the client side to store theme information. At a bare minimum you need to do the following: @@ -55,48 +50,26 @@ import { cookies } from 'next/headers'; import { Html, ThemeProvider } from '@designcise/next-theme-toggle'; import { getColors } from '@designcise/next-theme-toggle/server'; -// 1: specify key for cookie storage +// 1: specify key for storage const THEME_STORAGE_KEY = 'theme-preference'; const color = getColors(); export default async function RootLayout() { - // 2.1: get the user theme preference value from cookie, if one exists - // 2.2: set a default value in case the cookie doesn't exist (e.g. `?? color.light`) - const theme = cookies().get(THEME_STORAGE_KEY)?.value ?? color.light; - - // 3.1: use the `Html` component to prevent flicker - // 3.2: wrap components with `ThemeProvider` to pass theme down to all components + // 2: wrap components with `ThemeProvider` to pass theme props down to all components + // 3: pass `storageKey` and (optional) `defaultTheme` to `ThemeProvider` return ( - + - + {children} - + ) } ``` -The `Html` component is added for convenience. If you do not wish to use it, then you can achieve the same with the native `html` element in the following way: - -```jsx -// replace: - - -// with: - -``` - -You may also choose to not do this step altogether and pass `autoAntiFlicker={true}` (or just `autoAntiFlicker`) to the `ThemeProvider` component, which will automatically inject a script into DOM that takes care of this for you. For example: - -```jsx - -``` - -All these approaches help you avoid flicker on initial page load. - -> **NOTE**: Please note that using the script injection method will show the `Warning: Extra attributes from the server: class,style` warning in console in the dev environment only. This is unavoidable unfortunately, as it 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. +With this setup, the `ThemeProvider` component will automatically inject an inline script into DOM that takes care of avoiding flicker on initial page load. 2. Create a button to toggle between light and dark theme: @@ -204,12 +177,11 @@ 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. | -| `theme` | String | Starting theme; can be `'light'` or `'dark'`. | -| `autoAntiFlicker` | Boolean | If `true`, injects an inline anti-flicker script to DOM. | +| 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. | ### `useTheme()` @@ -231,7 +203,7 @@ Returns an object, with the following: | `light` | String | `'light'` | Color value used for light theme. | | `theme` | String | `'dark'`. | Color value used for dark theme. | -> **NOTE**: The `getColors()` function can be used in both, the client components and server components. +> **NOTE**: The `getColors()` function can be used in both, client components and server components. For server components you can import `getColors()` like so: @@ -296,6 +268,10 @@ 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. + ## Contributing https://github.com/designcise/next-theme-toggle/blob/main/CONTRIBUTING.md @@ -303,3 +279,8 @@ https://github.com/designcise/next-theme-toggle/blob/main/CONTRIBUTING.md ## License 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). + diff --git a/__tests__/ThemeProvider.test.jsx b/__tests__/ThemeProvider.test.jsx index 4683e01..910ff26 100644 --- a/__tests__/ThemeProvider.test.jsx +++ b/__tests__/ThemeProvider.test.jsx @@ -1,12 +1,14 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import { ThemeProvider } from '../src/client'; -import { clearAllDeviceCookies, setDeviceCookie, setDeviceTheme } from './assets/device.helper'; +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'; beforeEach(() => { - clearAllDeviceCookies(); + mockDeviceStorage(); + clear(); document.documentElement.style.colorScheme = '' document.documentElement.removeAttribute('class'); }); @@ -15,16 +17,16 @@ describe('provider', () => { test.each([ 'light', 'dark', - ])('should set `colorScheme` and class name to "%s" theme according to saved preference', (theme) => { + ])('should use the `defaultTheme` when nothing is stored in `localStorage`', (theme) => { const storageKey = 'test'; - setDeviceCookie(storageKey, theme); render( - + ); + expect(read(storageKey)).toEqual(theme); expect(document.documentElement.classList[0]).toBe(theme); expect(document.documentElement.style.colorScheme).toBe(theme); }); @@ -32,9 +34,26 @@ describe('provider', () => { test.each([ 'light', 'dark', - ])('should set `colorScheme` and class name to system resolved %s theme', (theme) => { + ])('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'; - setDeviceTheme(theme); + mockPreferredColorScheme(theme); render( @@ -42,6 +61,7 @@ describe('provider', () => { ); + expect(read(storageKey)).toEqual(theme); expect(document.documentElement.classList[0]).toBe(theme); expect(document.documentElement.style.colorScheme).toBe(theme); }); @@ -49,12 +69,12 @@ describe('provider', () => { test.each([ ['light', 'dark'], ['dark', 'light'], - ])('should ignore nested `ThemeProvider`', (defaultTheme, expectedTheme) => { + ])('should ignore nested `ThemeProvider`', (expectedTheme, nestedTheme) => { const storageKey = 'test'; render( - - + + @@ -66,44 +86,51 @@ describe('provider', () => { test.each([ ['light', 'dark'], ['dark', 'light'], - ])('should set cookie when toggling from "%s" to "%s" theme', (themeFrom, themeTo) => { + ])('should update value in storage when toggling from "%s" to "%s" theme', (themeFrom, themeTo) => { + const storageKey = 'test'; + render( - + ); - expect(document.cookie).toEqual(expect.stringMatching(new RegExp(`^test=${themeFrom}`))); + expect(read(storageKey)).toEqual(themeFrom); fireEvent.click(screen.getByText(/toggle theme/i)); - expect(document.cookie).toEqual(expect.stringMatching(new RegExp(`^test=${themeTo}`))); + expect(read(storageKey)).toEqual(themeTo); }); test.each([ ['light', 'dark'], ['dark', 'light'], - ])('should set cookie when manually setting theme from "%s" to "%s"', (themeFrom, themeTo) => { + ])('should update value in storage when manually setting theme from "%s" to "%s"', (themeFrom, themeTo) => { + const storageKey = 'test'; + render( - + ); - expect(document.cookie).toEqual(expect.stringMatching(new RegExp(`^test=${themeFrom}`))); + expect(read(storageKey)).toEqual(themeFrom); fireEvent.click(screen.getByText(/toggle theme/i)); - expect(document.cookie).toEqual(expect.stringMatching(new RegExp(`^test=${themeTo}`))); + expect(read(storageKey)).toEqual(themeTo); }); - test('should set cookie name according to the specified `storageKey`', () => { + test('should set storage key according to the specified `storageKey`', () => { + const storageKey = 'theme-test'; + const expectedTheme = 'light'; + render( - + ); - expect(document.cookie).toEqual(expect.stringMatching(/^theme-test=light/)); + expect(read(storageKey)).toEqual(expectedTheme); }); }); diff --git a/__tests__/assets/device.helper.js b/__tests__/assets/device.helper.js deleted file mode 100644 index e6633f0..0000000 --- a/__tests__/assets/device.helper.js +++ /dev/null @@ -1,24 +0,0 @@ -export function setDeviceTheme(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 setDeviceCookie(name, value) { - Object.defineProperty(window.document, 'cookie', { - writable: true, - value: `${name}=${value}`, - }); -} - -export function clearAllDeviceCookies() { - document.cookie.split(";").forEach(function(c) { document.cookie = c.replace(/^ +/, "").replace(/=.*/, "=;expires=" + new Date().toUTCString() + ";path=/"); }); -} diff --git a/__tests__/assets/device.mock.js b/__tests__/assets/device.mock.js new file mode 100644 index 0000000..76ee0cf --- /dev/null +++ b/__tests__/assets/device.mock.js @@ -0,0 +1,38 @@ +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__/useTheme.test.jsx b/__tests__/useTheme.test.jsx index 27742af..ac72ce0 100644 --- a/__tests__/useTheme.test.jsx +++ b/__tests__/useTheme.test.jsx @@ -1,14 +1,16 @@ import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import { ThemeProvider } from '../src/client'; -import { clearAllDeviceCookies, setDeviceTheme } from './assets/device.helper'; +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'; beforeEach(() => { - clearAllDeviceCookies(); + mockDeviceStorage(); + clear(); document.documentElement.style.colorScheme = '' document.documentElement.removeAttribute('class'); }); @@ -21,7 +23,7 @@ describe('useTheme', () => { const storageKey = 'test'; render( - + ); @@ -40,7 +42,7 @@ describe('useTheme', () => { ['dark', 'light'], ])('should toggle system resolved "%s" theme to "%s"', (themeFrom, themeTo) => { const storageKey = 'sys-resolved-theme'; - setDeviceTheme(themeFrom); + mockPreferredColorScheme(themeFrom); render( @@ -64,7 +66,7 @@ describe('useTheme', () => { const storageKey = 'test'; render( - + ); @@ -83,9 +85,10 @@ describe('useTheme', () => { 'dark', ])('should get "%s" as the active theme', (theme) => { const storageKey = 'user-theme'; + const oppositeTheme = (theme === 'light') ? 'dark' : 'light'; render( - + ); @@ -93,6 +96,6 @@ describe('useTheme', () => { fireEvent.click(screen.getByText(new RegExp(`${theme} theme`, 'i'))); expect(screen.getByText(`Active Theme: ${theme}`)).toBeInTheDocument(); - expect(document.cookie).toEqual(expect.stringMatching(new RegExp(`${storageKey}=${theme}`))); + expect(read(storageKey)).toEqual(theme); }); }); diff --git a/package.json b/package.json index 9c54d82..415cbed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@designcise/next-theme-toggle", - "version": "1.1.1", + "version": "2.0.0", "description": "A simple theme toggle for Next.js 13+", "exports": { ".": "./dist/client.js", diff --git a/src/adapter/storage.adapter.js b/src/adapter/storage.adapter.js index 3288bb1..cc1ed91 100644 --- a/src/adapter/storage.adapter.js +++ b/src/adapter/storage.adapter.js @@ -1,16 +1,13 @@ -export const read = (key) => { - const matches = `; ${document.cookie}`.match(`;\\s*${key}=([^;]+)`); - return matches?.[1] ?? null; -} - -export const write = (key, value, ttl = 365) => { - const date = new Date(); - date.setTime(date.getTime() + (ttl * 24 * 60 * 60 * 1000)); - const expires = `; expires=${date.toGMTString()}`; +export const read = (key) => localStorage.getItem(key); - document.cookie = `${key}=${value}${expires}; path=/`; +export const write = (key, value) => { + localStorage.setItem(key, value); } export const erase = (key) => { - write(key, '', -1); + localStorage.removeItem(key); +} + +export const clear = () => { + localStorage.clear(); } diff --git a/src/component/AntiFlickerScript.jsx b/src/component/AntiFlickerScript.jsx index 740e53e..679a9ca 100644 --- a/src/component/AntiFlickerScript.jsx +++ b/src/component/AntiFlickerScript.jsx @@ -1,9 +1,12 @@ import React, { memo } from 'react'; -export default memo(function AntiFlickerScript({ theme, color }) { +export default memo(function AntiFlickerScript({ storageKey, defaultTheme, color }) { const classList = Object.values(color).join("','"); - const script = '(function(theme,root){' + 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;` - + `})('${theme}',document.firstElementChild)`; + + `})(document.documentElement)`; return