diff options
Diffstat (limited to 'src/utils/providers/theme-provider')
| -rw-r--r-- | src/utils/providers/theme-provider/index.ts | 1 | ||||
| -rw-r--r-- | src/utils/providers/theme-provider/theme-provider.test.tsx | 78 | ||||
| -rw-r--r-- | src/utils/providers/theme-provider/theme-provider.tsx | 88 |
3 files changed, 167 insertions, 0 deletions
diff --git a/src/utils/providers/theme-provider/index.ts b/src/utils/providers/theme-provider/index.ts new file mode 100644 index 0000000..3df4bde --- /dev/null +++ b/src/utils/providers/theme-provider/index.ts @@ -0,0 +1 @@ +export * from './theme-provider'; diff --git a/src/utils/providers/theme-provider/theme-provider.test.tsx b/src/utils/providers/theme-provider/theme-provider.test.tsx new file mode 100644 index 0000000..59a72cc --- /dev/null +++ b/src/utils/providers/theme-provider/theme-provider.test.tsx @@ -0,0 +1,78 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '@testing-library/react'; +import { type FC, useContext } from 'react'; +import { getThemeFromSystem } from '../../helpers'; +import { ThemeContext, ThemeProvider } from './theme-provider'; + +const bodyPrefix = 'Current theme is:'; + +const ComponentTest: FC = () => { + const { theme } = useContext(ThemeContext); + + return ( + <div> + {bodyPrefix} {theme} + </div> + ); +}; + +describe('ThemeProvider', () => { + it('uses the default value when the provider is not used', () => { + const defaultValue = 'system'; + + render(<ComponentTest />); + + expect(rtlScreen.getByText(new RegExp(bodyPrefix))).toHaveTextContent( + `${bodyPrefix} ${defaultValue}` + ); + }); + + it('provides the given value to its children and set a matching attribute', () => { + const attribute = 'iure'; + const theme = 'dark'; + + const { baseElement } = render( + <ThemeProvider + attribute={attribute} + defaultTheme={theme} + storageKey="dolores" + > + <ComponentTest /> + </ThemeProvider> + ); + + expect(rtlScreen.getByText(new RegExp(bodyPrefix))).toHaveTextContent( + `${bodyPrefix} ${theme}` + ); + expect(baseElement.parentElement?.getAttribute(`data-${attribute}`)).toBe( + theme + ); + expect(baseElement.parentElement).toHaveStyle(`color-scheme: ${theme};`); + }); + + it('can resolve the preferred theme from user system settings', () => { + const attribute = 'qui'; + const defaultTheme = 'system'; + const resolvedTheme = getThemeFromSystem(); + + const { baseElement } = render( + <ThemeProvider + attribute={attribute} + defaultTheme={defaultTheme} + storageKey="modi" + > + <ComponentTest /> + </ThemeProvider> + ); + + expect(rtlScreen.getByText(new RegExp(bodyPrefix))).toHaveTextContent( + `${bodyPrefix} ${defaultTheme}` + ); + expect(baseElement.parentElement?.getAttribute(`data-${attribute}`)).toBe( + resolvedTheme + ); + expect(baseElement.parentElement).toHaveStyle( + `color-scheme: ${resolvedTheme};` + ); + }); +}); diff --git a/src/utils/providers/theme-provider/theme-provider.tsx b/src/utils/providers/theme-provider/theme-provider.tsx new file mode 100644 index 0000000..b743a7f --- /dev/null +++ b/src/utils/providers/theme-provider/theme-provider.tsx @@ -0,0 +1,88 @@ +import { + type Dispatch, + type FC, + type ReactNode, + type SetStateAction, + createContext, + useMemo, + useEffect, +} from 'react'; +import { getDataAttributeFrom, getThemeFromSystem } from '../../helpers'; +import { useLocalStorage, useSystemColorScheme } from '../../hooks'; + +const validThemes = ['dark', 'light', 'system'] as const; + +type Theme = (typeof validThemes)[number]; + +type ThemeContextProps = { + resolvedTheme: Exclude<Theme, 'system'>; + setTheme: Dispatch<SetStateAction<Theme>>; + theme: Theme; +}; + +export const ThemeContext = createContext<ThemeContextProps>({ + resolvedTheme: getThemeFromSystem(), + setTheme: (value) => value, + theme: 'system', +}); + +const validator = (value: unknown): value is Theme => + typeof value === 'string' && + (validThemes as readonly string[]).includes(value); + +export type ThemeProviderProps = { + /** + * The attribute name to append to document root. + */ + attribute: string; + /** + * The provider children. + */ + children?: ReactNode; + /** + * The default theme. + * + * @default 'system' + */ + defaultTheme?: Theme; + /** + * The key to use in local storage. + */ + storageKey: string; +}; + +export const ThemeProvider: FC<ThemeProviderProps> = ({ + attribute, + children, + defaultTheme = 'system', + storageKey, +}) => { + const [theme, setTheme] = useLocalStorage( + storageKey, + defaultTheme, + validator + ); + const userColorScheme = useSystemColorScheme(); + const resolvedTheme: Exclude<Theme, 'system'> = + theme === 'system' ? userColorScheme : theme; + const dataAttribute = getDataAttributeFrom(attribute); + + useEffect(() => { + if (typeof window !== 'undefined') { + document.documentElement.setAttribute(dataAttribute, `${resolvedTheme}`); + document.documentElement.style.colorScheme = resolvedTheme; + } + + return () => { + document.documentElement.removeAttribute(dataAttribute); + }; + }, [dataAttribute, resolvedTheme]); + + const value = useMemo(() => { + return { resolvedTheme, setTheme, theme }; + }, [resolvedTheme, setTheme, theme]); + + return ( + <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> + ); +}; |
