diff --git a/package-lock.json b/package-lock.json index 1604a4df..cb6faa6f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11248,9 +11248,9 @@ } }, "node_modules/commander": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.1.tgz", - "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", "dev": true, "license": "MIT", "engines": { diff --git a/src/tedi/providers/theme-provider/theme-provider.spec.tsx b/src/tedi/providers/theme-provider/theme-provider.spec.tsx index 51d73f42..9afd382b 100644 --- a/src/tedi/providers/theme-provider/theme-provider.spec.tsx +++ b/src/tedi/providers/theme-provider/theme-provider.spec.tsx @@ -18,6 +18,7 @@ describe('ThemeProvider', () => { const TestComponent = () => { const { theme, setTheme } = useTheme(); + return (

{theme}

@@ -53,7 +54,7 @@ describe('ThemeProvider', () => { expect(document.cookie).toContain('tedi-theme=default'); }); - it('ignores existing localStorage theme and uses provided prop theme instead', () => { + it('uses theme from localStorage over provided prop theme', () => { localStorage.setItem('tedi-theme', 'dark'); render( @@ -62,11 +63,10 @@ describe('ThemeProvider', () => { ); - // Provider should still use "default" because prop theme overrides storage - expect(screen.getByTestId('theme')).toHaveTextContent('default'); - expect(document.documentElement).toHaveClass('tedi-theme--default'); - expect(localStorage.getItem('tedi-theme')).toBe('default'); - expect(document.cookie).toContain('tedi-theme=default'); + expect(screen.getByTestId('theme')).toHaveTextContent('dark'); + expect(document.documentElement).toHaveClass('tedi-theme--dark'); + expect(localStorage.getItem('tedi-theme')).toBe('dark'); + expect(document.cookie).toContain('tedi-theme=dark'); }); it('updates theme and document classes when setTheme is called', () => { @@ -76,10 +76,8 @@ describe('ThemeProvider', () => { ); - const button = screen.getByText('Set Dark'); - act(() => { - button.click(); + screen.getByText('Set Dark').click(); }); expect(screen.getByTestId('theme')).toHaveTextContent('dark'); @@ -133,29 +131,31 @@ describe('ThemeProvider', () => { expect(document.cookie).toContain('tedi-theme=rit'); }); - it('does not set theme if invalid theme is provided', () => { - const TestComponentWithInvalidTheme = () => { + it('allows custom theme values', () => { + const TestComponentWithCustomTheme = () => { const { theme, setTheme } = useTheme(); + return (

{theme}

- +
); }; render( - + ); act(() => { - screen.getByText('Set Invalid').click(); + screen.getByText('Set Custom').click(); }); - expect(screen.getByTestId('theme')).toHaveTextContent('default'); - expect(document.documentElement).toHaveClass('tedi-theme--default'); - expect(localStorage.getItem('tedi-theme')).toBe('default'); + expect(screen.getByTestId('theme')).toHaveTextContent('invalid'); + expect(document.documentElement).toHaveClass('tedi-theme--invalid'); + expect(localStorage.getItem('tedi-theme')).toBe('invalid'); + expect(document.cookie).toContain('tedi-theme=invalid'); }); }); diff --git a/src/tedi/providers/theme-provider/theme-provider.tsx b/src/tedi/providers/theme-provider/theme-provider.tsx index 543cb732..d989115d 100644 --- a/src/tedi/providers/theme-provider/theme-provider.tsx +++ b/src/tedi/providers/theme-provider/theme-provider.tsx @@ -1,10 +1,10 @@ 'use client'; -import { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import { createContext, useContext, useEffect, useState } from 'react'; -export type Theme = 'default' | 'dark' | 'rit' | 'muis'; +type TEDITheme = 'default' | 'dark'; +export type Theme = TEDITheme | string; -const AVAILABLE_THEMES: Theme[] = ['default', 'dark', 'rit', 'muis']; const THEME_CLASS_PREFIX = 'tedi-theme--'; const STORAGE_KEY = 'tedi-theme'; @@ -20,40 +20,44 @@ const ThemeContext = createContext({ export const useTheme = () => useContext(ThemeContext); -export const ThemeProvider = ({ theme: initialTheme, children }: { theme?: Theme; children: React.ReactNode }) => { - const [theme, setThemeState] = useState(() => { - if (initialTheme !== undefined) { - return initialTheme; - } +function getInitialTheme(initialTheme?: Theme): Theme { + if (typeof window === 'undefined') { + return initialTheme ?? 'default'; + } - if (typeof window !== 'undefined') { - const saved = localStorage.getItem(STORAGE_KEY) as Theme | null; - if (saved && AVAILABLE_THEMES.includes(saved)) { - return saved; - } - } + const fromStorage = localStorage.getItem(STORAGE_KEY); + if (fromStorage) return fromStorage; + + const cookieMatch = document.cookie + .split('; ') + .find((c) => c.startsWith(`${STORAGE_KEY}=`)) + ?.split('=')[1]; + + if (cookieMatch) return cookieMatch; + + return initialTheme ?? 'default'; +} - return 'default'; - }); +export const ThemeProvider = ({ theme: initialTheme, children }: { theme?: Theme; children: React.ReactNode }) => { + const [theme, setTheme] = useState(() => getInitialTheme(initialTheme)); useEffect(() => { if (typeof document === 'undefined') return; const root = document.documentElement; - AVAILABLE_THEMES.forEach((t) => { - root.classList.toggle(`${THEME_CLASS_PREFIX}${t}`, t === theme); - }); + for (let i = root.classList.length - 1; i >= 0; i--) { + const cls = root.classList.item(i); + if (cls?.startsWith(THEME_CLASS_PREFIX)) { + root.classList.remove(cls); + } + } + + root.classList.add(`${THEME_CLASS_PREFIX}${theme}`); localStorage.setItem(STORAGE_KEY, theme); - document.cookie = `${STORAGE_KEY}=${theme};path=/;max-age=31536000`; + document.cookie = `${STORAGE_KEY}=${theme}; path=/; max-age=31536000; SameSite=Lax`; }, [theme]); - const setTheme = useCallback((newTheme: Theme) => { - if (AVAILABLE_THEMES.includes(newTheme)) { - setThemeState(newTheme); - } - }, []); - return {children}; };