From 5ddd8c23db470796bdde2803ba7856e09a3a931d Mon Sep 17 00:00:00 2001 From: Yiheng Date: Sun, 22 Sep 2024 10:58:28 +0800 Subject: [PATCH] feat: add useTheme (#2617) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: useTheme * test: useTheme * feat: useTheme add localStorageKey * docs: useTheme add localStorageKey * Update packages/hooks/src/useTheme/index.en-US.md Co-authored-by: 云泥 <1656081615@qq.com> * Update packages/hooks/src/useTheme/index.ts add listener type Co-authored-by: 云泥 <1656081615@qq.com> * 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: 云泥 <1656081615@qq.com> Co-authored-by: lxrsuper <1076629390@qq.com> --- config/hooks.ts | 1 + jest.config.js | 2 +- match-media-mock.js | 13 ++++ packages/hooks/src/index.ts | 2 + .../hooks/src/useTheme/__test__/index.test.ts | 29 ++++++++ packages/hooks/src/useTheme/demo/demo1.tsx | 47 ++++++++++++ packages/hooks/src/useTheme/index.en-US.md | 36 ++++++++++ packages/hooks/src/useTheme/index.ts | 71 +++++++++++++++++++ packages/hooks/src/useTheme/index.zh-CN.md | 36 ++++++++++ 9 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 match-media-mock.js create mode 100644 packages/hooks/src/useTheme/__test__/index.test.ts create mode 100644 packages/hooks/src/useTheme/demo/demo1.tsx create mode 100644 packages/hooks/src/useTheme/index.en-US.md create mode 100644 packages/hooks/src/useTheme/index.ts create mode 100644 packages/hooks/src/useTheme/index.zh-CN.md diff --git a/config/hooks.ts b/config/hooks.ts index fa74274cf2..26ffff428a 100644 --- a/config/hooks.ts +++ b/config/hooks.ts @@ -31,6 +31,7 @@ export const menus = [ 'useCounter', 'useTextSelection', 'useWebSocket', + 'useTheme', ], }, { diff --git a/jest.config.js b/jest.config.js index 23c1d38339..824400839e 100644 --- a/jest.config.js +++ b/jest.config.js @@ -14,7 +14,7 @@ module.exports = { testPathIgnorePatterns: ['/.history/'], modulePathIgnorePatterns: ['/package.json'], resetMocks: false, - setupFiles: ['./jest.setup.js', 'jest-localstorage-mock'], + setupFiles: ['./jest.setup.js', 'jest-localstorage-mock', './match-media-mock.js'], setupFilesAfterEnv: ['@testing-library/jest-dom/extend-expect'], transform: { '^.+\\.tsx?$': ['ts-jest', { tsconfig: 'tsconfig.json' }], diff --git a/match-media-mock.js b/match-media-mock.js new file mode 100644 index 0000000000..099fa51778 --- /dev/null +++ b/match-media-mock.js @@ -0,0 +1,13 @@ +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(), + })), +}); diff --git a/packages/hooks/src/index.ts b/packages/hooks/src/index.ts index f977e5ff86..55c7232b0d 100644 --- a/packages/hooks/src/index.ts +++ b/packages/hooks/src/index.ts @@ -75,6 +75,7 @@ import useVirtualList from './useVirtualList'; import useWebSocket from './useWebSocket'; import useWhyDidYouUpdate from './useWhyDidYouUpdate'; import useMutationObserver from './useMutationObserver'; +import useTheme from './useTheme'; export { useRequest, @@ -156,4 +157,5 @@ export { useRafTimeout, useResetState, useMutationObserver, + useTheme, }; diff --git a/packages/hooks/src/useTheme/__test__/index.test.ts b/packages/hooks/src/useTheme/__test__/index.test.ts new file mode 100644 index 0000000000..3343269439 --- /dev/null +++ b/packages/hooks/src/useTheme/__test__/index.test.ts @@ -0,0 +1,29 @@ +import { act, renderHook } from '@testing-library/react'; +import useTheme from '../index'; + +describe('useTheme', () => { + test('themeMode init', () => { + const { result } = renderHook(useTheme); + expect(result.current.themeMode).toBe('system'); + }); + + test('setThemeMode light', () => { + const { result } = renderHook(useTheme); + act(() => result.current.setThemeMode('light')); + expect(result.current.theme).toBe('light'); + expect(result.current.themeMode).toBe('light'); + }); + + test('setThemeMode dark', () => { + const { result } = renderHook(useTheme); + act(() => result.current.setThemeMode('dark')); + expect(result.current.theme).toBe('dark'); + expect(result.current.themeMode).toBe('dark'); + }); + + test('setThemeMode system', () => { + const { result } = renderHook(useTheme); + act(() => result.current.setThemeMode('system')); + expect(result.current.themeMode).toBe('system'); + }); +}); diff --git a/packages/hooks/src/useTheme/demo/demo1.tsx b/packages/hooks/src/useTheme/demo/demo1.tsx new file mode 100644 index 0000000000..8dd7708cc1 --- /dev/null +++ b/packages/hooks/src/useTheme/demo/demo1.tsx @@ -0,0 +1,47 @@ +/** + * title: Basic usage + * 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. + * + * title.zh-CN: 基础用法 + * desc.zh-CN: 'theme' 为系统当前显示主题("light" 或 "dark"),'themeMode' 为当前主题设置("light" 或 "dark" 或 "system")。 + */ + +import { useTheme } from 'ahooks'; +import React from 'react'; + +export default () => { + const { theme, themeMode, setThemeMode } = useTheme({ + localStorageKey: 'themeMode', + }); + + return ( + <> +
theme: {theme}
+
themeMode: {themeMode}
+ + + + + ); +}; diff --git a/packages/hooks/src/useTheme/index.en-US.md b/packages/hooks/src/useTheme/index.en-US.md new file mode 100644 index 0000000000..f5bdb1c2fe --- /dev/null +++ b/packages/hooks/src/useTheme/index.en-US.md @@ -0,0 +1,36 @@ +--- +nav: + path: /hooks +--- + +# useTheme + +This hook is used to get and set the theme, and store the `themeMode` into `localStorage`. + +## Examples + +### Default usage + + + +## API + +```typescript +const { theme, themeMode, setThemeMode } = useTheme({ + localStorageKey?: string; +}); +``` + +### Params + +| Property | Description | Type | Default | +| --------------- | ----------------------------------------------------- | -------- | --------- | +| localStorageKey | The key in localStorage to store selected theme mode | `string` | `undefined` | + +### Result + +| Property | Description | Type | Default | +| ------------ | --------------------- | ----------------------------------------------- | ------------------------------------------------------------------------------------- | +| theme | current display theme | `"light" \| "dark"` | if themeMode is "system" then equals to system setting,otherwise equals to themeMode | +| themeMode | selected theme mode | `"light" \| "dark" \| "system"` | equals to localStorage "themeMode", otherwise equals to "system" | +| setThemeMode | select theme mode | `(mode: "light" \| "dark" \| "system") => void` | | diff --git a/packages/hooks/src/useTheme/index.ts b/packages/hooks/src/useTheme/index.ts new file mode 100644 index 0000000000..9eb3899dcc --- /dev/null +++ b/packages/hooks/src/useTheme/index.ts @@ -0,0 +1,71 @@ +import { useEffect, useState } from 'react'; +import useMemoizedFn from '../useMemoizedFn'; + +export enum ThemeMode { + LIGHT = 'light', + DARK = 'dark', + SYSTEM = 'system', +} + +export type ThemeModeType = `${ThemeMode}`; + +export type ThemeType = 'light' | 'dark'; + +const matchMedia = window.matchMedia('(prefers-color-scheme: dark)'); + +function useCurrentTheme() { + const [theme, setTheme] = useState(() => { + const init = matchMedia.matches ? ThemeMode.DARK : ThemeMode.LIGHT; + return init; + }); + + useEffect(() => { + const onThemeChange: MediaQueryList['onchange'] = (event) => { + if (event.matches) { + setTheme(ThemeMode.DARK); + } else { + setTheme(ThemeMode.LIGHT); + } + }; + + matchMedia.addEventListener('change', onThemeChange); + + return () => { + matchMedia.removeEventListener('change', onThemeChange); + }; + }, []); + + return theme; +} + +type Options = { + localStorageKey?: string; +}; + +export default function useTheme(options: Options = {}) { + const { localStorageKey } = options; + + const [themeMode, setThemeMode] = useState(() => { + const preferredThemeMode = + localStorageKey?.length && (localStorage.getItem(localStorageKey) as ThemeModeType | null); + + return preferredThemeMode ? preferredThemeMode : ThemeMode.SYSTEM; + }); + + const setThemeModeWithLocalStorage = (mode: ThemeModeType) => { + setThemeMode(mode); + + if (localStorageKey?.length) { + localStorage.setItem(localStorageKey, mode); + } + }; + + const currentTheme = useCurrentTheme(); + const theme = themeMode === ThemeMode.SYSTEM ? currentTheme : themeMode; + + return { + theme, + themeMode, + setThemeMode: useMemoizedFn(setThemeModeWithLocalStorage), + }; +} diff --git a/packages/hooks/src/useTheme/index.zh-CN.md b/packages/hooks/src/useTheme/index.zh-CN.md new file mode 100644 index 0000000000..37c90800d9 --- /dev/null +++ b/packages/hooks/src/useTheme/index.zh-CN.md @@ -0,0 +1,36 @@ +--- +nav: + path: /hooks +--- + +# useTheme + +获取并设置当前主题,并将 `themeMode` 存储在 `localStorage` 中。 + +## 代码演示 + +### 基础用法 + + + +## API + +```typescript +const { theme, themeMode, setThemeMode } = useTheme({ + localStorageKey?: string; +}); +``` + +### 参数 + +| 参数 | 说明 | 类型 | 默认值 | +| --------------- | ------------------------------------ | -------- | --------- | +| localStorageKey | localStorage 中用于存放主题模式的键 | `string` | `undefined` | + +### 返回值 + +| 值 | 说明 | 类型 | 默认值 | +| ------------ | -------------- | ----------------------------------------------- | ---------------------------------------------------------------------- | +| theme | 当前显示的主题 | `"light" \| "dark"` | 若 themeMode 为 "system" 则为系统当前使用主题,否则与 themeMode 值相同 | +| themeMode | 选择的主题模式 | `"light" \| "dark" \| "system"` | 等于 localStorage "themeMode" 字段的值,否则为 "system" | +| setThemeMode | 选择主题模式 | `(mode: "light" \| "dark" \| "system") => void` | |