Skip to content

Commit

Permalink
refactor(tooling): Split Theme into Theme and ThemePreference (#4239
Browse files Browse the repository at this point in the history
)

* refactor(tooling): Split Theme into `Theme` and `ThemePreference`

* fmt

* fixes

* fix: Avoid setting the preference to system on every load

* typo

* fmt

* fix explorer

* chore: Add `iota-` prefixes to theme localstorage key in dashboard and wallet

* fix: Proper loading reactivity

---------

Co-authored-by: evavirseda <[email protected]>
  • Loading branch information
marc2332 and evavirseda authored Nov 29, 2024
1 parent 28aeefc commit 5494cff
Show file tree
Hide file tree
Showing 11 changed files with 96 additions and 78 deletions.
86 changes: 59 additions & 27 deletions apps/core/src/components/providers/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright (c) 2024 IOTA Stiftung
// SPDX-License-Identifier: Apache-2.0

import { PropsWithChildren, useState, useEffect, useCallback } from 'react';
import { Theme } from '../../enums';
import { PropsWithChildren, useState, useEffect } from 'react';
import { Theme, ThemePreference } from '../../enums';
import { ThemeContext } from '../../contexts';

interface ThemeProviderProps {
Expand All @@ -12,40 +12,72 @@ interface ThemeProviderProps {
export function ThemeProvider({ children, appId }: PropsWithChildren<ThemeProviderProps>) {
const storageKey = `theme_${appId}`;

const getSystemTheme = () =>
window.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.Dark : Theme.Light;
const getSystemTheme = () => {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? Theme.Dark : Theme.Light;
};

const getInitialTheme = () => {
if (typeof window === 'undefined') {
return Theme.System;
} else {
const storedTheme = localStorage?.getItem(storageKey);
return storedTheme ? (storedTheme as Theme) : Theme.System;
}
const getThemePreference = () => {
const storedTheme = localStorage?.getItem(storageKey) as ThemePreference | null;
return storedTheme ? storedTheme : ThemePreference.System;
};

const [theme, setTheme] = useState<Theme>(getInitialTheme);
const [systemTheme, setSystemTheme] = useState<Theme>(Theme.Light);
const [themePreference, setThemePreference] = useState<ThemePreference>(ThemePreference.System);
const [isLoadingPreference, setIsLoadingPreference] = useState(true);

const applyTheme = useCallback((currentTheme: Theme) => {
const selectedTheme = currentTheme === Theme.System ? getSystemTheme() : currentTheme;
const documentElement = document.documentElement.classList;
documentElement.toggle(Theme.Dark, selectedTheme === Theme.Dark);
documentElement.toggle(Theme.Light, selectedTheme === Theme.Light);
// Load the theme values on client
useEffect(() => {
if (typeof window === 'undefined') return;

setSystemTheme(getSystemTheme());
setThemePreference(getThemePreference());

// Make the theme preference listener wait
// until the preference is loaded in the next render
setIsLoadingPreference(false);
}, []);

// When the theme preference changes..
useEffect(() => {
if (typeof window === 'undefined') return;
if (typeof window === 'undefined' || isLoadingPreference) return;

// Update localStorage with the new preference
localStorage.setItem(storageKey, themePreference);

localStorage.setItem(storageKey, theme);
applyTheme(theme);
// In case of SystemPreference, listen for system theme changes
if (themePreference === ThemePreference.System) {
const handleSystemThemeChange = () => {
const systemTheme = getSystemTheme();
setSystemTheme(systemTheme);
};
const systemThemeMatcher = window.matchMedia('(prefers-color-scheme: dark)');
systemThemeMatcher.addEventListener('change', handleSystemThemeChange);
return () => systemThemeMatcher.removeEventListener('change', handleSystemThemeChange);
}
}, [themePreference, storageKey, isLoadingPreference]);

if (theme === Theme.System) {
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemThemeChange = () => applyTheme(Theme.System);
systemTheme.addEventListener('change', handleSystemThemeChange);
return () => systemTheme.removeEventListener('change', handleSystemThemeChange);
// Derive the active theme from the preference
const theme = (() => {
switch (themePreference) {
case ThemePreference.Dark:
return Theme.Dark;
case ThemePreference.Light:
return Theme.Light;
case ThemePreference.System:
return systemTheme;
}
}, [theme, applyTheme, storageKey]);
})();

// When the theme (preference or derived) changes update the CSS class
useEffect(() => {
const documentElement = document.documentElement.classList;
documentElement.toggle(Theme.Dark, theme === Theme.Dark);
documentElement.toggle(Theme.Light, theme === Theme.Light);
}, [theme]);

return <ThemeContext.Provider value={{ theme, setTheme }}>{children}</ThemeContext.Provider>;
return (
<ThemeContext.Provider value={{ theme, setThemePreference, themePreference }}>
{children}
</ThemeContext.Provider>
);
}
8 changes: 5 additions & 3 deletions apps/core/src/contexts/ThemeContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
// SPDX-License-Identifier: Apache-2.0

import { createContext } from 'react';
import { Theme } from '../enums';
import { Theme, ThemePreference } from '../enums';

export interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
themePreference: ThemePreference;
setThemePreference: (theme: ThemePreference) => void;
}

export const ThemeContext = createContext<ThemeContextType>({
theme: Theme.Light,
setTheme: () => {},
themePreference: ThemePreference.System,
setThemePreference: () => {},
});
5 changes: 5 additions & 0 deletions apps/core/src/enums/theme.enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,10 @@
export enum Theme {
Light = 'light',
Dark = 'dark',
}

export enum ThemePreference {
Light = 'light',
Dark = 'dark',
System = 'system',
}
39 changes: 7 additions & 32 deletions apps/explorer/src/components/header/ThemeSwitcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,46 +3,21 @@

import { Button, ButtonType } from '@iota/apps-ui-kit';
import { DarkMode, LightMode } from '@iota/ui-icons';
import { useEffect, useLayoutEffect } from 'react';
import { useTheme, Theme } from '@iota/core';
import { useTheme, Theme, ThemePreference } from '@iota/core';

export function ThemeSwitcher(): React.JSX.Element {
const { theme, setTheme } = useTheme();
const { theme, themePreference, setThemePreference } = useTheme();

const ThemeIcon = theme === Theme.Dark ? DarkMode : LightMode;

function handleOnClick(): void {
const newTheme = theme === Theme.Light ? Theme.Dark : Theme.Light;
setTheme(newTheme);
saveThemeToLocalStorage(newTheme);
const newTheme =
themePreference === ThemePreference.Light
? ThemePreference.Dark
: ThemePreference.Light;
setThemePreference(newTheme);
}

function saveThemeToLocalStorage(newTheme: Theme): void {
localStorage.setItem('theme', newTheme);
}

function updateDocumentClass(theme: Theme): void {
document.documentElement.classList.toggle('dark', theme === Theme.Dark);
}

useLayoutEffect(() => {
const storedTheme = localStorage.getItem('theme') as Theme | null;
if (storedTheme) {
setTheme(storedTheme);
updateDocumentClass(storedTheme);
} else {
const prefersDarkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
const preferredTheme = prefersDarkTheme ? Theme.Dark : Theme.Light;

setTheme(preferredTheme);
updateDocumentClass(preferredTheme);
}
}, []);

useEffect(() => {
updateDocumentClass(theme);
}, [theme]);

return (
<Button
type={ButtonType.Ghost}
Expand Down
11 changes: 7 additions & 4 deletions apps/wallet-dashboard/app/(protected)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@ import { Notifications } from '@/components/index';
import React, { type PropsWithChildren } from 'react';
import { Button } from '@iota/apps-ui-kit';
import { Sidebar, TopNav } from './components';
import { Theme, useTheme } from '@iota/core';
import { ThemePreference, useTheme } from '@iota/core';

function DashboardLayout({ children }: PropsWithChildren): JSX.Element {
const { theme, setTheme } = useTheme();
const { theme, themePreference, setThemePreference } = useTheme();

const toggleTheme = () => {
const newTheme = theme === Theme.Light ? Theme.Dark : Theme.Light;
setTheme(newTheme);
const newTheme =
themePreference === ThemePreference.Light
? ThemePreference.Dark
: ThemePreference.Light;
setThemePreference(newTheme);
};

return (
Expand Down
6 changes: 3 additions & 3 deletions apps/wallet-dashboard/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ function HomeDashboardPage(): JSX.Element {
<main className="flex h-screen">
<div className="relative hidden sm:flex md:w-1/3">
<video
key={theme}
src={videoSrc}
autoPlay
muted
loop
className="absolute right-0 top-0 h-full w-full min-w-fit object-cover"
disableRemotePlayback
>
<source src={videoSrc} type="video/mp4" />
</video>
></video>
</div>
<div className="flex h-full w-full flex-col items-center justify-between p-md sm:p-2xl">
<IotaLogoWeb width={130} height={32} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export function StartStaking() {
</div>
<div className="relative w-full overflow-hidden">
<video
key={videoSrc}
src={videoSrc}
autoPlay
loop
Expand Down
2 changes: 1 addition & 1 deletion apps/wallet-dashboard/providers/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export function AppProviders({ children }: React.PropsWithChildren) {
},
]}
>
<ThemeProvider appId="dashboard">
<ThemeProvider appId="iota-dashboard">
<PopupProvider>
{children}
<Toaster />
Expand Down
10 changes: 5 additions & 5 deletions apps/wallet/src/ui/app/components/menu/content/ThemeSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,24 @@
// SPDX-License-Identifier: Apache-2.0

import { RadioButton } from '@iota/apps-ui-kit';
import { Theme, useTheme } from '@iota/core';
import { ThemePreference, useTheme } from '@iota/core';
import { Overlay } from '_components';
import { useNavigate } from 'react-router-dom';

export function ThemeSettings() {
const { theme, setTheme } = useTheme();
const { themePreference, setThemePreference } = useTheme();

const navigate = useNavigate();

return (
<Overlay showModal title="Theme" closeOverlay={() => navigate('/')} showBackButton>
<div className="flex w-full flex-col">
{Object.entries(Theme).map(([key, value]) => (
{Object.entries(ThemePreference).map(([key, value]) => (
<div className="px-md" key={value}>
<RadioButton
label={key}
isChecked={theme === value}
onChange={() => setTheme(value)}
isChecked={themePreference === value}
onChange={() => setThemePreference(value)}
/>
</div>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { ampli } from '_src/shared/analytics/ampli';
import { useTheme, getCustomNetwork } from '@iota/core';

function MenuList() {
const { theme } = useTheme();
const { themePreference } = useTheme();
const navigate = useNavigate();
const activeAccount = useActiveAccount();
const networkUrl = useNextMenuUrl(true, '/network');
Expand Down Expand Up @@ -84,7 +84,7 @@ function MenuList() {
}

const autoLockSubtitle = handleAutoLockSubtitle();
const themeSubtitle = theme.charAt(0).toUpperCase() + theme.slice(1);
const themeSubtitle = themePreference.charAt(0).toUpperCase() + themePreference.slice(1);
const MENU_ITEMS = [
{
title: 'Network',
Expand Down
2 changes: 1 addition & 1 deletion apps/wallet/src/ui/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ function AppWrapper() {
>
<KioskClientProvider>
<AccountsFormProvider>
<ThemeProvider appId="wallet">
<ThemeProvider appId="iota-wallet">
<UnlockAccountProvider>
<div
className={cn(
Expand Down

0 comments on commit 5494cff

Please sign in to comment.