Skip to content

Commit 9124809

Browse files
authored
Merge pull request #1 from designcise/feat/localstorage
feat: use `localStorage` instead of cookies
2 parents a54a7b7 + 640e768 commit 9124809

10 files changed

+149
-129
lines changed

README.md

+31-50
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,21 @@
11
# next-theme-toggle
22

3-
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).
3+
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:
44

5-
## Goals
5+
```html
6+
<html class="dark" style="color-scheme:dark">
7+
```
68

7-
The goal of the project is to:
9+
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).
10+
11+
## Goals
812

913
- Provide an easy way of toggling between light and dark themes
1014
- Auto-switch theme on page load based on system settings
1115
- Avoid flicker on page load
1216
- Have no unnecessary bloat
1317
- Have very minimal configuration
14-
15-
## Expectations
16-
17-
Result of using this package will be that the following are added to the `<html>` element:
18-
19-
```html
20-
<html class="dark" style="color-scheme:dark">
21-
```
22-
23-
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).
18+
- Be simple and intuitive
2419

2520
## Installation
2621

@@ -43,7 +38,7 @@ $ yarn add @designcise/next-theme-toggle
4338

4439
## Quickstart
4540

46-
> **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.
41+
> **NOTE**: Please note that this approach relies on using `localStorage` on the client side to store theme information.
4742
4843
At a bare minimum you need to do the following:
4944

@@ -55,48 +50,26 @@ import { cookies } from 'next/headers';
5550
import { Html, ThemeProvider } from '@designcise/next-theme-toggle';
5651
import { getColors } from '@designcise/next-theme-toggle/server';
5752

58-
// 1: specify key for cookie storage
53+
// 1: specify key for storage
5954
const THEME_STORAGE_KEY = 'theme-preference';
6055
const color = getColors();
6156

6257
export default async function RootLayout() {
63-
// 2.1: get the user theme preference value from cookie, if one exists
64-
// 2.2: set a default value in case the cookie doesn't exist (e.g. `?? color.light`)
65-
const theme = cookies().get(THEME_STORAGE_KEY)?.value ?? color.light;
66-
67-
// 3.1: use the `Html` component to prevent flicker
68-
// 3.2: wrap components with `ThemeProvider` to pass theme down to all components
58+
// 2: wrap components with `ThemeProvider` to pass theme props down to all components
59+
// 3: pass `storageKey` and (optional) `defaultTheme` to `ThemeProvider`
6960
return (
70-
<Html theme={theme}>
61+
<html>
7162
<body>
72-
<ThemeProvider storageKey={THEME_STORAGE_KEY} theme={theme}>
63+
<ThemeProvider storageKey={THEME_STORAGE_KEY} defaultTheme={color.dark}>
7364
{children}
7465
</ThemeProvider>
7566
</body>
76-
</Html>
67+
</html>
7768
)
7869
}
7970
```
8071

81-
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:
82-
83-
```jsx
84-
// replace:
85-
<Html theme={theme}>
86-
87-
// with:
88-
<html className={theme} style={{ colorScheme: theme }}>
89-
```
90-
91-
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:
92-
93-
```jsx
94-
<ThemeProvider storageKey={THEME_STORAGE_KEY} theme={theme} autoAntiFlicker>
95-
```
96-
97-
All these approaches help you avoid flicker on initial page load.
98-
99-
> **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.
72+
With this setup, the `ThemeProvider` component will automatically inject an inline script into DOM that takes care of avoiding flicker on initial page load.
10073

10174
2. Create a button to toggle between light and dark theme:
10275

@@ -204,12 +177,11 @@ That's it! You should have light/dark theme toggle in your Next.js application.
204177

205178
You can pass the following props to `ThemeProvider`:
206179

207-
| Prop | Type | Description |
208-
|-------------------|:--------------------------------------------:|:------------------------------------------------------------:|
209-
| `children` | `React.ReactChild`&vert;`React.ReactChild[]` | Components to which the theme is passed down to via context. |
210-
| `storageKey` | String | Name of the key used for storage. |
211-
| `theme` | String | Starting theme; can be `'light'` or `'dark'`. |
212-
| `autoAntiFlicker` | Boolean | If `true`, injects an inline anti-flicker script to DOM. |
180+
| Prop | Type | Description |
181+
|----------------|:--------------------------------------------:|:------------------------------------------------------------------:|
182+
| `children` | `React.ReactChild`&vert;`React.ReactChild[]` | Components to which the theme is passed down to via context. |
183+
| `storageKey` | String | Name of the key used for storage. |
184+
| `defaultTheme` | String | Default theme (`'light'` or `'dark'`) to use on initial page load. |
213185

214186
### `useTheme()`
215187

@@ -231,7 +203,7 @@ Returns an object, with the following:
231203
| `light` | String | `'light'` | Color value used for light theme. |
232204
| `theme` | String | `'dark'`. | Color value used for dark theme. |
233205

234-
> **NOTE**: The `getColors()` function can be used in both, the client components and server components.
206+
> **NOTE**: The `getColors()` function can be used in both, client components and server components.
235207
236208
For server components you can import `getColors()` like so:
237209

@@ -296,10 +268,19 @@ To fix this, you can add the folder where your CSS or SASS file is located. For
296268
// ...
297269
```
298270

271+
#### `Warning: Extra attributes from the server: class,style` in Console
272+
273+
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.
274+
299275
## Contributing
300276

301277
https://github.com/designcise/next-theme-toggle/blob/main/CONTRIBUTING.md
302278

303279
## License
304280

305281
https://github.com/designcise/next-theme-toggle/blob/main/LICENSE.md
282+
283+
## Resources
284+
285+
- [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).
286+

__tests__/ThemeProvider.test.jsx

+48-21
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import React from 'react';
22
import { render, screen, fireEvent } from '@testing-library/react';
33
import { ThemeProvider } from '../src/client';
4-
import { clearAllDeviceCookies, setDeviceCookie, setDeviceTheme } from './assets/device.helper';
4+
import { mockDeviceStorage, mockPreferredColorScheme } from './assets/device.mock';
5+
import { read, write, clear } from '../src/adapter/storage.adapter';
56
import ThemeAutoToggle from './assets/ThemeAutoToggle';
67
import ThemeManualToggle from './assets/ThemeManualToggle';
78

89
beforeEach(() => {
9-
clearAllDeviceCookies();
10+
mockDeviceStorage();
11+
clear();
1012
document.documentElement.style.colorScheme = ''
1113
document.documentElement.removeAttribute('class');
1214
});
@@ -15,46 +17,64 @@ describe('provider', () => {
1517
test.each([
1618
'light',
1719
'dark',
18-
])('should set `colorScheme` and class name to "%s" theme according to saved preference', (theme) => {
20+
])('should use the `defaultTheme` when nothing is stored in `localStorage`', (theme) => {
1921
const storageKey = 'test';
20-
setDeviceCookie(storageKey, theme);
2122

2223
render(
23-
<ThemeProvider storageKey={storageKey} theme={theme}>
24+
<ThemeProvider storageKey={storageKey} defaultTheme={theme}>
2425
<ThemeAutoToggle />
2526
</ThemeProvider>
2627
);
2728

29+
expect(read(storageKey)).toEqual(theme);
2830
expect(document.documentElement.classList[0]).toBe(theme);
2931
expect(document.documentElement.style.colorScheme).toBe(theme);
3032
});
3133

3234
test.each([
3335
'light',
3436
'dark',
35-
])('should set `colorScheme` and class name to system resolved %s theme', (theme) => {
37+
])('should set `color-scheme` and `class` to "%s" theme according to saved preference', (theme) => {
38+
const storageKey = 'test';
39+
write(storageKey, theme);
40+
41+
render(
42+
<ThemeProvider storageKey={storageKey}>
43+
<ThemeAutoToggle />
44+
</ThemeProvider>
45+
);
46+
47+
expect(document.documentElement.classList[0]).toBe(theme);
48+
expect(document.documentElement.style.colorScheme).toBe(theme);
49+
});
50+
51+
test.each([
52+
'light',
53+
'dark',
54+
])('should set resolve to system resolved theme "%s"', (theme) => {
3655
const storageKey = 'sys-resolved-theme';
37-
setDeviceTheme(theme);
56+
mockPreferredColorScheme(theme);
3857

3958
render(
4059
<ThemeProvider storageKey={storageKey}>
4160
<ThemeAutoToggle />
4261
</ThemeProvider>
4362
);
4463

64+
expect(read(storageKey)).toEqual(theme);
4565
expect(document.documentElement.classList[0]).toBe(theme);
4666
expect(document.documentElement.style.colorScheme).toBe(theme);
4767
});
4868

4969
test.each([
5070
['light', 'dark'],
5171
['dark', 'light'],
52-
])('should ignore nested `ThemeProvider`', (defaultTheme, expectedTheme) => {
72+
])('should ignore nested `ThemeProvider`', (expectedTheme, nestedTheme) => {
5373
const storageKey = 'test';
5474

5575
render(
56-
<ThemeProvider storageKey={storageKey} theme={expectedTheme}>
57-
<ThemeProvider storageKey={storageKey} theme={defaultTheme}>
76+
<ThemeProvider storageKey={storageKey} defaultTheme={expectedTheme}>
77+
<ThemeProvider storageKey={storageKey} defaultTheme={nestedTheme}>
5878
<ThemeAutoToggle />
5979
</ThemeProvider>
6080
</ThemeProvider>
@@ -66,44 +86,51 @@ describe('provider', () => {
6686
test.each([
6787
['light', 'dark'],
6888
['dark', 'light'],
69-
])('should set cookie when toggling from "%s" to "%s" theme', (themeFrom, themeTo) => {
89+
])('should update value in storage when toggling from "%s" to "%s" theme', (themeFrom, themeTo) => {
90+
const storageKey = 'test';
91+
7092
render(
71-
<ThemeProvider storageKey="test" theme={themeFrom}>
93+
<ThemeProvider storageKey={storageKey} defaultTheme={themeFrom}>
7294
<ThemeAutoToggle />
7395
</ThemeProvider>
7496
);
7597

76-
expect(document.cookie).toEqual(expect.stringMatching(new RegExp(`^test=${themeFrom}`)));
98+
expect(read(storageKey)).toEqual(themeFrom);
7799

78100
fireEvent.click(screen.getByText(/toggle theme/i));
79101

80-
expect(document.cookie).toEqual(expect.stringMatching(new RegExp(`^test=${themeTo}`)));
102+
expect(read(storageKey)).toEqual(themeTo);
81103
});
82104

83105
test.each([
84106
['light', 'dark'],
85107
['dark', 'light'],
86-
])('should set cookie when manually setting theme from "%s" to "%s"', (themeFrom, themeTo) => {
108+
])('should update value in storage when manually setting theme from "%s" to "%s"', (themeFrom, themeTo) => {
109+
const storageKey = 'test';
110+
87111
render(
88-
<ThemeProvider storageKey="test" theme={themeFrom}>
112+
<ThemeProvider storageKey={storageKey} defaultTheme={themeFrom}>
89113
<ThemeManualToggle />
90114
</ThemeProvider>
91115
);
92116

93-
expect(document.cookie).toEqual(expect.stringMatching(new RegExp(`^test=${themeFrom}`)));
117+
expect(read(storageKey)).toEqual(themeFrom);
94118

95119
fireEvent.click(screen.getByText(/toggle theme/i));
96120

97-
expect(document.cookie).toEqual(expect.stringMatching(new RegExp(`^test=${themeTo}`)));
121+
expect(read(storageKey)).toEqual(themeTo);
98122
});
99123

100-
test('should set cookie name according to the specified `storageKey`', () => {
124+
test('should set storage key according to the specified `storageKey`', () => {
125+
const storageKey = 'theme-test';
126+
const expectedTheme = 'light';
127+
101128
render(
102-
<ThemeProvider storageKey="theme-test" theme="light">
129+
<ThemeProvider storageKey={storageKey} defaultTheme={expectedTheme}>
103130
<ThemeAutoToggle />
104131
</ThemeProvider>
105132
);
106133

107-
expect(document.cookie).toEqual(expect.stringMatching(/^theme-test=light/));
134+
expect(read(storageKey)).toEqual(expectedTheme);
108135
});
109136
});

__tests__/assets/device.helper.js

-24
This file was deleted.

__tests__/assets/device.mock.js

+38
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export function mockPreferredColorScheme(theme) {
2+
Object.defineProperty(window, 'matchMedia', {
3+
writable: true,
4+
value: jest.fn().mockImplementation(query => ({
5+
matches: theme === 'dark',
6+
media: query,
7+
onchange: null,
8+
addEventListener: jest.fn(),
9+
removeEventListener: jest.fn(),
10+
dispatchEvent: jest.fn(),
11+
}))
12+
})
13+
}
14+
15+
export function mockDeviceStorage() {
16+
const localStorageMock = (function() {
17+
let store = {}
18+
19+
return {
20+
getItem: function(key) {
21+
return store[key] || null;
22+
},
23+
setItem: function(key, value) {
24+
store[key] = value.toString();
25+
},
26+
removeItem: function(key) {
27+
delete store[key];
28+
},
29+
clear: function() {
30+
store = {};
31+
},
32+
};
33+
})();
34+
35+
Object.defineProperty(window, 'localStorage', {
36+
value: localStorageMock,
37+
});
38+
}

0 commit comments

Comments
 (0)