diff options
22 files changed, 361 insertions, 31 deletions
diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index f3f374f..d5cc8cc 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -1,13 +1,17 @@ import type { Decorator, Preview } from '@storybook/react'; -import { ThemeProvider, useTheme } from 'next-themes'; import { useDarkMode } from 'storybook-dark-mode'; import { FC, ReactNode, useEffect } from 'react'; import { IntlProvider } from 'react-intl'; -import { AckeeProvider, MotionProvider } from '../src/utils/providers'; +import { + AckeeProvider, + MotionProvider, + ThemeProvider, +} from '../src/utils/providers'; import '../src/styles/globals.scss'; import { DocsContainer } from './overrides/docs-container'; import dark from './themes/dark'; import light from './themes/light'; +import { useTheme } from '../src/utils/hooks'; type ThemeWrapperProps = { children: ReactNode; @@ -28,11 +32,7 @@ export const ThemeWrapper: FC<ThemeWrapperProps> = ({ children }) => { const withAllProviders: Decorator = (Story) => { return ( <IntlProvider locale="en"> - <ThemeProvider - defaultTheme="system" - enableColorScheme={true} - enableSystem={true} - > + <ThemeProvider attribute="theme" storageKey="theme"> <MotionProvider attribute="reduced-motion" storageKey="reduced-motion"> <AckeeProvider domainId="any" diff --git a/package.json b/package.json index 2def275..73bc6ac 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,6 @@ "modern-normalize": "^2.0.0", "next": "^13.4.19", "next-sitemap": "^4.2.3", - "next-themes": "^0.2.1", "prismjs": "^1.29.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/src/components/organisms/forms/theme-toggle/theme-toggle.tsx b/src/components/organisms/forms/theme-toggle/theme-toggle.tsx index 6026e1c..88f3c75 100644 --- a/src/components/organisms/forms/theme-toggle/theme-toggle.tsx +++ b/src/components/organisms/forms/theme-toggle/theme-toggle.tsx @@ -1,5 +1,4 @@ -import { useTheme } from 'next-themes'; -import { useCallback, type ChangeEvent, type FC } from 'react'; +import type { FC } from 'react'; import { useIntl } from 'react-intl'; import { Icon, Legend } from '../../../atoms'; import { @@ -7,6 +6,7 @@ import { type SwitchOption, type SwitchProps, } from '../../../molecules'; +import { useTheme } from 'src/utils/hooks'; export type ThemeToggleProps = Omit< SwitchProps, @@ -20,16 +20,9 @@ export type ThemeToggleProps = Omit< */ export const ThemeToggle: FC<ThemeToggleProps> = (props) => { const intl = useIntl(); - const { resolvedTheme, setTheme } = useTheme(); + const { resolvedTheme, toggleTheme } = useTheme(); const isDarkTheme = resolvedTheme === 'dark'; - const updateTheme = useCallback( - (e: ChangeEvent<HTMLInputElement>) => { - setTheme(e.target.value === 'light' ? 'light' : 'dark'); - }, - [setTheme] - ); - const themeLabel = intl.formatMessage({ defaultMessage: 'Theme:', description: 'ThemeToggle: theme label', @@ -76,7 +69,7 @@ export const ThemeToggle: FC<ThemeToggleProps> = (props) => { items={options} legend={<Legend>{themeLabel}</Legend>} name="theme" - onSwitch={updateTheme} + onSwitch={toggleTheme} value={isDarkTheme ? 'dark' : 'light'} /> ); diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index c332432..0c92c93 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -1,5 +1,4 @@ import { useRouter } from 'next/router'; -import { ThemeProvider } from 'next-themes'; import { IntlProvider } from 'react-intl'; import '../styles/globals.scss'; import type { AppPropsWithLayout } from '../types'; @@ -9,6 +8,7 @@ import { AckeeProvider, MotionProvider, PrismThemeProvider, + ThemeProvider, } from '../utils/providers'; const App = ({ Component, pageProps }: AppPropsWithLayout) => { @@ -34,9 +34,8 @@ const App = ({ Component, pageProps }: AppPropsWithLayout) => { messages={translation} > <ThemeProvider - defaultTheme="system" - enableColorScheme={true} - enableSystem={true} + attribute={STORAGE_KEY.THEME} + storageKey={STORAGE_KEY.THEME} > <PrismThemeProvider> {getLayout(<Component {...componentProps} />, {})} diff --git a/src/pages/_document.tsx b/src/pages/_document.tsx index 317d3af..7e3b5e6 100644 --- a/src/pages/_document.tsx +++ b/src/pages/_document.tsx @@ -16,6 +16,15 @@ export default function Document() { // eslint-disable-next-line react/jsx-no-literals strategy="beforeInteractive" /> + <Script + dangerouslySetInnerHTML={{ + __html: `!function(){const e=localStorage.getItem("${STORAGE_KEY.THEME}"),t="string"==typeof e?JSON.parse(e):void 0,o=window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light",c=e&&["dark","light"].includes(t)?t:o;document.documentElement.setAttribute("data-${STORAGE_KEY.THEME}",c),document.documentElement.style.colorScheme=o}();`, + }} + // eslint-disable-next-line react/jsx-no-literals + id="theme-hydration" + // eslint-disable-next-line react/jsx-no-literals + strategy="beforeInteractive" + /> </Head> <body> <Main /> diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 62acca5..0c64f3a 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -34,4 +34,5 @@ export const ROUTES = { export const STORAGE_KEY = { ACKEE: 'ackee-tracking', MOTION: 'reduced-motion', + THEME: 'theme', } as const; diff --git a/src/utils/helpers/index.ts b/src/utils/helpers/index.ts index b2a4534..14487e6 100644 --- a/src/utils/helpers/index.ts +++ b/src/utils/helpers/index.ts @@ -4,3 +4,4 @@ export * from './pages'; export * from './rss'; export * from './schema-org'; export * from './strings'; +export * from './themes'; diff --git a/src/utils/helpers/themes.ts b/src/utils/helpers/themes.ts new file mode 100644 index 0000000..8ef3a19 --- /dev/null +++ b/src/utils/helpers/themes.ts @@ -0,0 +1,18 @@ +/** + * Check if the user prefers dark color scheme. + * + * @returns {boolean|undefined} True if `prefers-color-scheme` is set to `dark`. + */ +export const prefersDarkScheme = (): boolean | undefined => { + if (typeof window === 'undefined') return undefined; + + return window.matchMedia('(prefers-color-scheme: dark)').matches; +}; + +/** + * Retrieve the theme to use depending on the user system theme. + */ +export const getThemeFromSystem = () => { + if (prefersDarkScheme()) return 'dark'; + return 'light'; +}; diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 606c259..4372ca0 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -10,6 +10,7 @@ export * from './use-headings-tree'; export * from './use-input-autofocus'; export * from './use-is-mounted'; export * from './use-local-storage'; +export * from './use-match-media'; export * from './use-mutation-observer'; export * from './use-on-click-outside'; export * from './use-pagination'; @@ -22,3 +23,5 @@ export * from './use-route-change'; export * from './use-scroll-position'; export * from './use-settings'; export * from './use-state-change'; +export * from './use-system-color-scheme'; +export * from './use-theme'; diff --git a/src/utils/hooks/use-match-media/index.ts b/src/utils/hooks/use-match-media/index.ts new file mode 100644 index 0000000..7d79e64 --- /dev/null +++ b/src/utils/hooks/use-match-media/index.ts @@ -0,0 +1 @@ +export * from './use-match-media'; diff --git a/src/utils/hooks/use-match-media/use-match-media.ts b/src/utils/hooks/use-match-media/use-match-media.ts new file mode 100644 index 0000000..50ac038 --- /dev/null +++ b/src/utils/hooks/use-match-media/use-match-media.ts @@ -0,0 +1,17 @@ +import { useEffect } from 'react'; + +export type UseMatchMediaCallback = (ev: MediaQueryListEvent) => void; + +/** + * React hook to watch for media changes based on the given query. + * + * @param {string} query - A media query. + * @param {UseMatchMediaCallback} cb - A callback function to execute on change. + */ +export const useMatchMedia = (query: string, cb: UseMatchMediaCallback) => { + useEffect(() => { + window.matchMedia(query).addEventListener('change', cb); + + return () => window.matchMedia(query).removeEventListener('change', cb); + }, [cb, query]); +}; diff --git a/src/utils/hooks/use-system-color-scheme/index.ts b/src/utils/hooks/use-system-color-scheme/index.ts new file mode 100644 index 0000000..78d1665 --- /dev/null +++ b/src/utils/hooks/use-system-color-scheme/index.ts @@ -0,0 +1 @@ +export * from './use-system-color-scheme'; diff --git a/src/utils/hooks/use-system-color-scheme/use-system-color-scheme.ts b/src/utils/hooks/use-system-color-scheme/use-system-color-scheme.ts new file mode 100644 index 0000000..1956f32 --- /dev/null +++ b/src/utils/hooks/use-system-color-scheme/use-system-color-scheme.ts @@ -0,0 +1,24 @@ +import { useCallback, useState } from 'react'; +import { getThemeFromSystem } from '../../helpers'; +import { useMatchMedia } from '../use-match-media'; + +export type SystemColorScheme = 'dark' | 'light'; + +/** + * React hook to retrieve the system color scheme based on user preferences, + * and to watch for changes. + * + * @returns {SystemColorScheme} The system color scheme + */ +export const useSystemColorScheme = () => { + const [colorScheme, setColorScheme] = + useState<SystemColorScheme>(getThemeFromSystem); + + const updateColorScheme = useCallback(() => { + setColorScheme(getThemeFromSystem); + }, []); + + useMatchMedia('(prefers-color-scheme: dark)', updateColorScheme); + + return colorScheme; +}; diff --git a/src/utils/hooks/use-theme/index.ts b/src/utils/hooks/use-theme/index.ts new file mode 100644 index 0000000..4e8fc4a --- /dev/null +++ b/src/utils/hooks/use-theme/index.ts @@ -0,0 +1 @@ +export * from './use-theme'; diff --git a/src/utils/hooks/use-theme/use-theme.test.tsx b/src/utils/hooks/use-theme/use-theme.test.tsx new file mode 100644 index 0000000..feaabfa --- /dev/null +++ b/src/utils/hooks/use-theme/use-theme.test.tsx @@ -0,0 +1,82 @@ +import { describe, expect, it } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; +import type { FC, ReactNode } from 'react'; +import { ThemeProvider, type ThemeProviderProps } from '../../providers'; +import { useTheme } from './use-theme'; + +const createWrapper = ( + Wrapper: FC<ThemeProviderProps>, + config: ThemeProviderProps +) => + function CreatedWrapper({ children }: { children: ReactNode }) { + return <Wrapper {...config}>{children}</Wrapper>; + }; + +describe('useTheme', () => { + it('should return the default value without provider and prevent update', () => { + const defaultTheme = 'system'; + const { result } = renderHook(() => useTheme()); + + expect(result.current.theme).toBe(defaultTheme); + + act(() => result.current.setTheme('dark')); + + expect(result.current.theme).toBe(defaultTheme); + }); + + it('can update the value', () => { + const defaultTheme = 'dark'; + + const { result } = renderHook(() => useTheme(), { + wrapper: createWrapper(ThemeProvider, { + attribute: 'magnam', + defaultTheme, + storageKey: 'repellat', + }), + }); + + expect(result.current.theme).toBe(defaultTheme); + + const newTheme = 'light'; + + act(() => result.current.setTheme(newTheme)); + + expect(result.current.theme).toBe(newTheme); + }); + + it('can toggle the theme from dark to light', () => { + const defaultTheme = 'dark'; + + const { result } = renderHook(() => useTheme(), { + wrapper: createWrapper(ThemeProvider, { + attribute: 'voluptatibus', + defaultTheme, + storageKey: 'qui', + }), + }); + + expect(result.current.theme).toBe(defaultTheme); + + act(() => result.current.toggleTheme()); + + expect(result.current.theme).toBe('light'); + }); + + it('can toggle the theme from light to dark', () => { + const defaultTheme = 'light'; + + const { result } = renderHook(() => useTheme(), { + wrapper: createWrapper(ThemeProvider, { + attribute: 'sed', + defaultTheme, + storageKey: 'ut', + }), + }); + + expect(result.current.theme).toBe(defaultTheme); + + act(() => result.current.toggleTheme()); + + expect(result.current.theme).toBe('dark'); + }); +}); diff --git a/src/utils/hooks/use-theme/use-theme.ts b/src/utils/hooks/use-theme/use-theme.ts new file mode 100644 index 0000000..0605d8b --- /dev/null +++ b/src/utils/hooks/use-theme/use-theme.ts @@ -0,0 +1,15 @@ +import { useCallback, useContext } from 'react'; +import { ThemeContext } from '../../providers'; + +export const useTheme = () => { + const { resolvedTheme, theme, setTheme } = useContext(ThemeContext); + + const toggleTheme = useCallback(() => { + setTheme(() => { + if (resolvedTheme === 'dark') return 'light'; + return 'dark'; + }); + }, [resolvedTheme, setTheme]); + + return { resolvedTheme, setTheme, theme, toggleTheme }; +}; diff --git a/src/utils/providers/index.ts b/src/utils/providers/index.ts index a01200a..90dca15 100644 --- a/src/utils/providers/index.ts +++ b/src/utils/providers/index.ts @@ -1,3 +1,4 @@ export * from './ackee-provider'; export * from './motion-provider'; export * from './prism-theme'; +export * from './theme-provider'; 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> + ); +}; diff --git a/tests/utils/index.tsx b/tests/utils/index.tsx index f86f6fa..2457285 100644 --- a/tests/utils/index.tsx +++ b/tests/utils/index.tsx @@ -2,10 +2,13 @@ import { render as rtlRender, type RenderOptions, } from '@testing-library/react'; -import { ThemeProvider } from 'next-themes'; import type { FC, ReactElement, ReactNode } from 'react'; import { IntlProvider } from 'react-intl'; -import { AckeeProvider, MotionProvider } from '../../src/utils/providers'; +import { + AckeeProvider, + MotionProvider, + ThemeProvider, +} from '../../src/utils/providers'; type ProvidersConfig = { children: ReactNode; @@ -24,7 +27,7 @@ type CustomRenderOptions = { */ const AllTheProviders: FC<ProvidersConfig> = ({ children, locale = 'en' }) => ( <IntlProvider locale={locale}> - <ThemeProvider> + <ThemeProvider attribute="theme" storageKey="theme"> <AckeeProvider domainId="any-id" server="https://example.test" @@ -11413,11 +11413,6 @@ next-sitemap@^4.2.3: fast-glob "^3.2.12" minimist "^1.2.8" -next-themes@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.2.1.tgz#0c9f128e847979daf6c67f70b38e6b6567856e45" - integrity sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A== - next@^13.4.19: version "13.4.19" resolved "https://registry.yarnpkg.com/next/-/next-13.4.19.tgz#2326e02aeedee2c693d4f37b90e4f0ed6882b35f" |
