Skip to content

Commit 5ddd8c2

Browse files
ianzoneliuyibcrazylxr
authored
feat: add useTheme (#2617)
* feat: useTheme * test: useTheme * feat: useTheme add localStorageKey * docs: useTheme add localStorageKey * Update packages/hooks/src/useTheme/index.en-US.md Co-authored-by: 云泥 <[email protected]> * Update packages/hooks/src/useTheme/index.ts add listener type Co-authored-by: 云泥 <[email protected]> * fix: missing localStorage.getItem(localStorageKey) * docs: useTheme add Params * feat: useTheme add default props * Update packages/hooks/src/useTheme/index.zh-CN.md * Update packages/hooks/src/useTheme/index.zh-CN.md * Update packages/hooks/src/useTheme/index.en-US.md * style: optimize * feat: useTheme add onChange callback * feat: remove onChange --------- Co-authored-by: 云泥 <[email protected]> Co-authored-by: lxrsuper <[email protected]>
1 parent 5c7281e commit 5ddd8c2

File tree

9 files changed

+236
-1
lines changed

9 files changed

+236
-1
lines changed

config/hooks.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export const menus = [
3131
'useCounter',
3232
'useTextSelection',
3333
'useWebSocket',
34+
'useTheme',
3435
],
3536
},
3637
{

jest.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ module.exports = {
1414
testPathIgnorePatterns: ['/.history/'],
1515
modulePathIgnorePatterns: ['<rootDir>/package.json'],
1616
resetMocks: false,
17-
setupFiles: ['./jest.setup.js', 'jest-localstorage-mock'],
17+
setupFiles: ['./jest.setup.js', 'jest-localstorage-mock', './match-media-mock.js'],
1818
setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'],
1919
transform: {
2020
'^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }],

match-media-mock.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
Object.defineProperty(window, 'matchMedia', {
2+
writable: true,
3+
value: jest.fn().mockImplementation((query) => ({
4+
matches: false,
5+
media: query,
6+
onchange: null,
7+
addListener: jest.fn(), // deprecated
8+
removeListener: jest.fn(), // deprecated
9+
addEventListener: jest.fn(),
10+
removeEventListener: jest.fn(),
11+
dispatchEvent: jest.fn(),
12+
})),
13+
});

packages/hooks/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ import useVirtualList from './useVirtualList';
7575
import useWebSocket from './useWebSocket';
7676
import useWhyDidYouUpdate from './useWhyDidYouUpdate';
7777
import useMutationObserver from './useMutationObserver';
78+
import useTheme from './useTheme';
7879

7980
export {
8081
useRequest,
@@ -156,4 +157,5 @@ export {
156157
useRafTimeout,
157158
useResetState,
158159
useMutationObserver,
160+
useTheme,
159161
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import useTheme from '../index';
3+
4+
describe('useTheme', () => {
5+
test('themeMode init', () => {
6+
const { result } = renderHook(useTheme);
7+
expect(result.current.themeMode).toBe('system');
8+
});
9+
10+
test('setThemeMode light', () => {
11+
const { result } = renderHook(useTheme);
12+
act(() => result.current.setThemeMode('light'));
13+
expect(result.current.theme).toBe('light');
14+
expect(result.current.themeMode).toBe('light');
15+
});
16+
17+
test('setThemeMode dark', () => {
18+
const { result } = renderHook(useTheme);
19+
act(() => result.current.setThemeMode('dark'));
20+
expect(result.current.theme).toBe('dark');
21+
expect(result.current.themeMode).toBe('dark');
22+
});
23+
24+
test('setThemeMode system', () => {
25+
const { result } = renderHook(useTheme);
26+
act(() => result.current.setThemeMode('system'));
27+
expect(result.current.themeMode).toBe('system');
28+
});
29+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
/**
2+
* title: Basic usage
3+
* desc: The 'theme' is the system display theme ("light" or "dark"), the 'themeMode' can set 'theme' to "light" or "dark" or follow the system setting.
4+
*
5+
* title.zh-CN: 基础用法
6+
* desc.zh-CN: 'theme' 为系统当前显示主题("light" 或 "dark"),'themeMode' 为当前主题设置("light" 或 "dark" 或 "system")。
7+
*/
8+
9+
import { useTheme } from 'ahooks';
10+
import React from 'react';
11+
12+
export default () => {
13+
const { theme, themeMode, setThemeMode } = useTheme({
14+
localStorageKey: 'themeMode',
15+
});
16+
17+
return (
18+
<>
19+
<div>theme: {theme}</div>
20+
<div>themeMode: {themeMode}</div>
21+
<button
22+
type="button"
23+
onClick={() => {
24+
setThemeMode('dark');
25+
}}
26+
>
27+
use dark theme
28+
</button>
29+
<button
30+
type="button"
31+
onClick={() => {
32+
setThemeMode('light');
33+
}}
34+
>
35+
use light theme
36+
</button>
37+
<button
38+
type="button"
39+
onClick={() => {
40+
setThemeMode('system');
41+
}}
42+
>
43+
follow the system
44+
</button>
45+
</>
46+
);
47+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
nav:
3+
path: /hooks
4+
---
5+
6+
# useTheme
7+
8+
This hook is used to get and set the theme, and store the `themeMode` into `localStorage`.
9+
10+
## Examples
11+
12+
### Default usage
13+
14+
<code src="./demo/demo1.tsx" />
15+
16+
## API
17+
18+
```typescript
19+
const { theme, themeMode, setThemeMode } = useTheme({
20+
localStorageKey?: string;
21+
});
22+
```
23+
24+
### Params
25+
26+
| Property | Description | Type | Default |
27+
| --------------- | ----------------------------------------------------- | -------- | --------- |
28+
| localStorageKey | The key in localStorage to store selected theme mode | `string` | `undefined` |
29+
30+
### Result
31+
32+
| Property | Description | Type | Default |
33+
| ------------ | --------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------- |
34+
| theme | current display theme | `"light" \| "dark"` | if themeMode is "system" then equals to system setting,otherwise equals to themeMode |
35+
| themeMode | selected theme mode | `"light" \| "dark" \| "system"` | equals to localStorage "themeMode", otherwise equals to "system" |
36+
| setThemeMode | select theme mode | `(mode: "light" \| "dark" \| "system") => void` | |

packages/hooks/src/useTheme/index.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { useEffect, useState } from 'react';
2+
import useMemoizedFn from '../useMemoizedFn';
3+
4+
export enum ThemeMode {
5+
LIGHT = 'light',
6+
DARK = 'dark',
7+
SYSTEM = 'system',
8+
}
9+
10+
export type ThemeModeType = `${ThemeMode}`;
11+
12+
export type ThemeType = 'light' | 'dark';
13+
14+
const matchMedia = window.matchMedia('(prefers-color-scheme: dark)');
15+
16+
function useCurrentTheme() {
17+
const [theme, setTheme] = useState<ThemeType>(() => {
18+
const init = matchMedia.matches ? ThemeMode.DARK : ThemeMode.LIGHT;
19+
return init;
20+
});
21+
22+
useEffect(() => {
23+
const onThemeChange: MediaQueryList['onchange'] = (event) => {
24+
if (event.matches) {
25+
setTheme(ThemeMode.DARK);
26+
} else {
27+
setTheme(ThemeMode.LIGHT);
28+
}
29+
};
30+
31+
matchMedia.addEventListener('change', onThemeChange);
32+
33+
return () => {
34+
matchMedia.removeEventListener('change', onThemeChange);
35+
};
36+
}, []);
37+
38+
return theme;
39+
}
40+
41+
type Options = {
42+
localStorageKey?: string;
43+
};
44+
45+
export default function useTheme(options: Options = {}) {
46+
const { localStorageKey } = options;
47+
48+
const [themeMode, setThemeMode] = useState<ThemeModeType>(() => {
49+
const preferredThemeMode =
50+
localStorageKey?.length && (localStorage.getItem(localStorageKey) as ThemeModeType | null);
51+
52+
return preferredThemeMode ? preferredThemeMode : ThemeMode.SYSTEM;
53+
});
54+
55+
const setThemeModeWithLocalStorage = (mode: ThemeModeType) => {
56+
setThemeMode(mode);
57+
58+
if (localStorageKey?.length) {
59+
localStorage.setItem(localStorageKey, mode);
60+
}
61+
};
62+
63+
const currentTheme = useCurrentTheme();
64+
const theme = themeMode === ThemeMode.SYSTEM ? currentTheme : themeMode;
65+
66+
return {
67+
theme,
68+
themeMode,
69+
setThemeMode: useMemoizedFn(setThemeModeWithLocalStorage),
70+
};
71+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
nav:
3+
path: /hooks
4+
---
5+
6+
# useTheme
7+
8+
获取并设置当前主题,并将 `themeMode` 存储在 `localStorage` 中。
9+
10+
## 代码演示
11+
12+
### 基础用法
13+
14+
<code src="./demo/demo1.tsx" />
15+
16+
## API
17+
18+
```typescript
19+
const { theme, themeMode, setThemeMode } = useTheme({
20+
localStorageKey?: string;
21+
});
22+
```
23+
24+
### 参数
25+
26+
| 参数 | 说明 | 类型 | 默认值 |
27+
| --------------- | ------------------------------------ | -------- | --------- |
28+
| localStorageKey | localStorage 中用于存放主题模式的键 | `string` | `undefined` |
29+
30+
### 返回值
31+
32+
|| 说明 | 类型 | 默认值 |
33+
| ------------ | -------------- | ----------------------------------------------- | ---------------------------------------------------------------------- |
34+
| theme | 当前显示的主题 | `"light" \| "dark"` | 若 themeMode 为 "system" 则为系统当前使用主题,否则与 themeMode 值相同 |
35+
| themeMode | 选择的主题模式 | `"light" \| "dark" \| "system"` | 等于 localStorage "themeMode" 字段的值,否则为 "system" |
36+
| setThemeMode | 选择主题模式 | `(mode: "light" \| "dark" \| "system") => void` | |

0 commit comments

Comments
 (0)