Skip to content

Commit

Permalink
✨ feat: AppContainer 支持亮暗模式主题切换
Browse files Browse the repository at this point in the history
  • Loading branch information
arvinxx committed Jan 7, 2023
1 parent 19b7502 commit bfd9922
Show file tree
Hide file tree
Showing 4 changed files with 211 additions and 71 deletions.
100 changes: 30 additions & 70 deletions src/containers/AppContainer/AppContainer.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,21 @@
import { memo, ReactElement, ReactNode, useMemo } from 'react';
import { memo, ReactElement, ReactNode } from 'react';

import { ThemeModeContext } from '@/context';
import { AntdStylish, AntdToken, DisplayTheme, FullToken, ThemeMode } from '@/types';
import { ThemeAppearance, ThemeMode } from '@/types';

import { useAntdTheme } from '@/hooks/useAntdTheme';
import { ThemeProvider } from '../ThemeProvider';
import { AntdProvider, type AntdProviderProps } from './AntdProvider';
import { type AntdProviderProps } from './AntdProvider';
import ThemeContent, { ThemeContentProps } from './ThemeContent';
import ThemeSwitcher from './ThemeSwitcher';

type GetCustomToken<T> = ({ token }: { token: AntdToken }) => T;

type GetCustomStylish<S> = ({ token, stylish }: { token: FullToken; stylish: AntdStylish }) => S;

export interface AppContainerProps<T, S = Record<string, string>> extends AntdProviderProps {
export interface AppContainerProps<T, S = Record<string, string>>
extends AntdProviderProps,
ThemeContentProps<T, S> {
/**
* 应用的展示外观主题,只存在亮色和暗色两种
* @default light
*/
appearance?: DisplayTheme;
defaultAppearance?: DisplayTheme;
onAppearanceChange?: (mode: DisplayTheme) => void;
appearance?: ThemeAppearance;
defaultAppearance?: ThemeAppearance;
onAppearanceChange?: (mode: ThemeAppearance) => void;
/**
* 主题的展示模式,有三种配置:跟随系统、亮色、暗色
* 默认不开启自动模式,需要手动进行配置
Expand All @@ -27,68 +24,31 @@ export interface AppContainerProps<T, S = Record<string, string>> extends AntdPr
themeMode?: ThemeMode;

children: ReactNode;
/**
* 自定义 Token
*/
customToken?: T | GetCustomToken<T>;
/**
* 自定义 Stylish
*/
customStylish?: S | GetCustomStylish<S>;

className?: string;
prefixCls?: string;
}

const Content: <T, S>(
props: Pick<AppContainerProps<T, S>, 'customStylish' | 'customToken'> &
AntdProviderProps & { children: ReactNode },
) => ReactElement | null = ({
children,
customToken: customTokenOrFn,
customStylish: stylishOrGetStylish,
...props
}) => {
const { stylish: antdStylish, ...token } = useAntdTheme();

// 获取 自定义 token
const customToken = useMemo(() => {
if (typeof customTokenOrFn === 'function') {
// @ts-ignore
return customTokenOrFn({ token });
}

return customTokenOrFn;
}, [token, customTokenOrFn]);

// 获取 stylish
const customStylish = useMemo(() => {
if (typeof stylishOrGetStylish === 'function') {
// @ts-ignore
return stylishOrGetStylish({ token: { ...token, ...customToken }, stylish: antdStylish });
}
return stylishOrGetStylish;
}, []);

return (
<AntdProvider {...props}>
<ThemeProvider customToken={customToken} customStylish={customStylish}>
{children}
</ThemeProvider>
</AntdProvider>
);
};

export const AppContainer: <T, S>(props: AppContainerProps<T, S>) => ReactElement | null = memo(
({ children, appearance, themeMode, customToken, customStylish, ...props }) => (
<ThemeModeContext.Provider
value={{
themeMode: themeMode || 'light',
appearance: appearance || 'light',
}}
({
children,
appearance,
defaultAppearance,
onAppearanceChange,
themeMode,
customToken,
customStylish,
...props
}) => (
<ThemeSwitcher
themeMode={themeMode}
defaultAppearance={defaultAppearance}
appearance={appearance}
onAppearanceChange={onAppearanceChange}
>
<Content customStylish={customStylish} customToken={customToken} {...props}>
<ThemeContent customStylish={customStylish} customToken={customToken} {...props}>
{children}
</Content>
</ThemeModeContext.Provider>
</ThemeContent>
</ThemeSwitcher>
),
);
90 changes: 90 additions & 0 deletions src/containers/AppContainer/ThemeContent.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { ReactElement, ReactNode, useMemo } from 'react';

import { AntdStylish, AntdToken, FullToken, ThemeAppearance } from '@/types';

import { useThemeMode } from '@/hooks';
import { useAntdTheme } from '@/hooks/useAntdTheme';
import { theme } from 'antd';
import { ThemeConfig } from 'antd/es/config-provider/context';
import { ThemeProvider } from '../ThemeProvider';
import { AntdProvider, type AntdProviderProps } from './AntdProvider';

export type GetCustomToken<T> = (theme: { token: AntdToken; appearance: ThemeAppearance }) => T;

export type GetCustomStylish<S> = (theme: {
token: FullToken;
stylish: AntdStylish;
appearance: ThemeAppearance;
}) => S;

export interface ThemeContentProps<T, S = Record<string, string>> extends AntdProviderProps {
children: ReactNode;
/**
* 自定义 Token
*/
customToken?: T | GetCustomToken<T>;
/**
* 自定义 Stylish
*/
customStylish?: S | GetCustomStylish<S>;
}

const ThemeContent: <T, S>(props: ThemeContentProps<T, S>) => ReactElement | null = ({
children,
customToken: customTokenOrFn,
customStylish: stylishOrGetStylish,
theme: themeProp,
...props
}) => {
const { appearance, isDarkMode } = useThemeMode();
const { stylish: antdStylish, ...token } = useAntdTheme();

// 获取 自定义 token
const customToken = useMemo(() => {
if (typeof customTokenOrFn === 'function') {
// @ts-ignore
return customTokenOrFn({ token, appearance });
}

return customTokenOrFn;
}, [customTokenOrFn, token, appearance]);

// 获取 stylish
const customStylish = useMemo(() => {
if (typeof stylishOrGetStylish === 'function') {
// @ts-ignore
return stylishOrGetStylish({ token: { ...token, ...customToken }, stylish: antdStylish });
}
return stylishOrGetStylish;
}, [stylishOrGetStylish, token, customToken, antdStylish, appearance]);

const antdTheme = useMemo<ThemeConfig>(() => {
const baseAlgorithm = isDarkMode ? theme.darkAlgorithm : theme.defaultAlgorithm;

if (!themeProp) {
return { algorithm: baseAlgorithm };
}

// 如果有 themeProp 说明是外部传入的 theme,需要对算法做一个合并处理,因此先把 themeProp 的算法规整为一个数组
const algoProp = !themeProp.algorithm
? []
: themeProp.algorithm instanceof Array
? themeProp.algorithm
: [themeProp.algorithm];

return {
...themeProp,
algorithm: !themeProp.algorithm ? baseAlgorithm : [baseAlgorithm, ...algoProp],
};
}, [themeProp, isDarkMode]);

return (
<AntdProvider theme={antdTheme} {...props}>
<ThemeProvider customToken={customToken} customStylish={customStylish}>
{children}
</ThemeProvider>
</AntdProvider>
);
};

export default ThemeContent;
90 changes: 90 additions & 0 deletions src/containers/AppContainer/ThemeSwitcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { FC, memo, ReactNode, useCallback, useLayoutEffect } from 'react';
import useControlledState from 'use-merge-value';

import { ThemeModeContext } from '@/context';
import { ThemeAppearance, ThemeMode } from '@/types';

let darkThemeMatch: MediaQueryList;

const matchThemeMode = (mode: ThemeAppearance) => matchMedia(`(prefers-color-scheme: ${mode})`);

export interface ThemeSwitcherProps {
/**
* 应用的展示外观主题,只存在亮色和暗色两种
* @default light
*/
appearance?: ThemeAppearance;
defaultAppearance?: ThemeAppearance;
onAppearanceChange?: (mode: ThemeAppearance) => void;
/**
* 主题的展示模式,有三种配置:跟随系统、亮色、暗色
* 默认不开启自动模式,需要手动进行配置
* @default light
*/
themeMode?: ThemeMode;

children: ReactNode;
}

const ThemeSwitcher: FC<ThemeSwitcherProps> = memo(
({
children,
appearance: appearanceProp,
defaultAppearance,
onAppearanceChange,
themeMode = 'light',
}) => {
const [appearance, setAppearance] = useControlledState<ThemeAppearance>('light', {
value: appearanceProp,
defaultValue: defaultAppearance,
onChange: onAppearanceChange,
});

const matchBrowserTheme = useCallback(() => {
if (matchThemeMode('dark').matches) {
setAppearance('dark');
} else {
setAppearance('light');
}
}, [setAppearance]);

useLayoutEffect(() => {
// 如果是自动的话,则去做一次匹配
if (themeMode === 'auto') matchBrowserTheme();
// 否则就明确设定亮暗色
else setAppearance(themeMode);
}, [themeMode]);

useLayoutEffect(() => {
if (!darkThemeMatch) {
darkThemeMatch = matchThemeMode('dark');
}

if (!themeMode || themeMode === 'auto') {
setTimeout(() => {
matchBrowserTheme();
}, 100);
}

darkThemeMatch.addEventListener('change', matchBrowserTheme);

return () => {
darkThemeMatch.removeEventListener('change', matchBrowserTheme);
};
}, []);

return (
<ThemeModeContext.Provider
value={{
themeMode,
appearance,
isDarkMode: appearance === 'dark',
}}
>
{children}
</ThemeModeContext.Provider>
);
},
);

export default ThemeSwitcher;
2 changes: 1 addition & 1 deletion src/types/appearance.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export type DisplayTheme = 'dark' | 'light';
export type ThemeAppearance = 'dark' | 'light';

export type ThemeMode = 'auto' | 'dark' | 'light';

0 comments on commit bfd9922

Please sign in to comment.