Skip to content

Commit

Permalink
Merge pull request #1 from designcise/feat/localstorage
Browse files Browse the repository at this point in the history
feat: use `localStorage` instead of cookies
  • Loading branch information
designcise committed Dec 9, 2023
2 parents a54a7b7 + 640e768 commit 9124809
Show file tree
Hide file tree
Showing 10 changed files with 149 additions and 129 deletions.
81 changes: 31 additions & 50 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 `<html>` element:

## Goals
```html
<html class="dark" style="color-scheme:dark">
```

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 `<html>` element:

```html
<html class="dark" style="color-scheme:dark">
```

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

Expand All @@ -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:

Expand All @@ -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 (
<Html theme={theme}>
<html>
<body>
<ThemeProvider storageKey={THEME_STORAGE_KEY} theme={theme}>
<ThemeProvider storageKey={THEME_STORAGE_KEY} defaultTheme={color.dark}>
{children}
</ThemeProvider>
</body>
</Html>
</html>
)
}
```

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:
<Html theme={theme}>

// with:
<html className={theme} style={{ colorScheme: theme }}>
```
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
<ThemeProvider storageKey={THEME_STORAGE_KEY} theme={theme} autoAntiFlicker>
```
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:

Expand Down Expand Up @@ -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`&vert;`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`&vert;`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()`

Expand All @@ -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:

Expand Down Expand Up @@ -296,10 +268,19 @@ 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

## 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).

69 changes: 48 additions & 21 deletions __tests__/ThemeProvider.test.jsx
Original file line number Diff line number Diff line change
@@ -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');
});
Expand All @@ -15,46 +17,64 @@ 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(
<ThemeProvider storageKey={storageKey} theme={theme}>
<ThemeProvider storageKey={storageKey} defaultTheme={theme}>
<ThemeAutoToggle />
</ThemeProvider>
);

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 `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(
<ThemeProvider storageKey={storageKey}>
<ThemeAutoToggle />
</ThemeProvider>
);

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(
<ThemeProvider storageKey={storageKey}>
<ThemeAutoToggle />
</ThemeProvider>
);

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`', (defaultTheme, expectedTheme) => {
])('should ignore nested `ThemeProvider`', (expectedTheme, nestedTheme) => {
const storageKey = 'test';

render(
<ThemeProvider storageKey={storageKey} theme={expectedTheme}>
<ThemeProvider storageKey={storageKey} theme={defaultTheme}>
<ThemeProvider storageKey={storageKey} defaultTheme={expectedTheme}>
<ThemeProvider storageKey={storageKey} defaultTheme={nestedTheme}>
<ThemeAutoToggle />
</ThemeProvider>
</ThemeProvider>
Expand All @@ -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(
<ThemeProvider storageKey="test" theme={themeFrom}>
<ThemeProvider storageKey={storageKey} defaultTheme={themeFrom}>
<ThemeAutoToggle />
</ThemeProvider>
);

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(
<ThemeProvider storageKey="test" theme={themeFrom}>
<ThemeProvider storageKey={storageKey} defaultTheme={themeFrom}>
<ThemeManualToggle />
</ThemeProvider>
);

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(
<ThemeProvider storageKey="theme-test" theme="light">
<ThemeProvider storageKey={storageKey} defaultTheme={expectedTheme}>
<ThemeAutoToggle />
</ThemeProvider>
);

expect(document.cookie).toEqual(expect.stringMatching(/^theme-test=light/));
expect(read(storageKey)).toEqual(expectedTheme);
});
});
24 changes: 0 additions & 24 deletions __tests__/assets/device.helper.js

This file was deleted.

38 changes: 38 additions & 0 deletions __tests__/assets/device.mock.js
Original file line number Diff line number Diff line change
@@ -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,
});
}
Loading

0 comments on commit 9124809

Please sign in to comment.