Данный гайд описывает один из возможных подходов к организации фичи темизации приложения.
Глобально статья поделена на 2 части:
База
Здесь мы опишем логику работы с темами, которая построена на чистом css (ну почти). Но этот механизм не имеет никаких "завязок" на js фреймворк/библиотеку.
React
В этой части мы посмотрим, как именно в контексте react приложения мы сможем эффеективно применять наши темы.
Что хотим получить
Предет тем, как приступим к решение задачи темизации нашего react приложения сформулируем возможности, которые должна обладать наша будущая система.
Возможность расширения
Мы не должны быть ограничены только одной цветовой темой. И нам не должно быть мучительно больно, если наши старшие братья по интерфейсам (дизайнеры) вдруг запилят к стандартным "светлой" и "тёмной" темам ещё и "малиновую, по случаю дня рождения компании".
Возможность изменить стилизацию приложения (css).
Это в общем то и есть основная цель - мы должны уметь легко применять цвета выбранной темы на наши элементы в css.
Возможность изменить стилизацию приложения (js)
Части наших элементов, может понадобится изменять своё отображение не только на основе css но и на основе свойств, которые мы пробрасываем внутрь компонентов. Например, так работают большинство библиотек элементов (закрыты к изменению, но имеют api для управления).
Следовательно где-то в коде уже наших компонентов мы должны иметь возможность получить значение текущей темы и работать с ним.
Возможность из любого места приложения инициировать изменение темы
В любой момент развития нашего проекта/продукта может изменится дизайн концепция и/или могут тестироваться раз��ичные гипотезы, которые могут привести к изменению нашего ui. Компоненты могут меняться местами, элементы управления уезжать "вглубь" или в порталы/модальные окна и т.д.
Для того, чтобы в реализации учесть эти возможные изменения мы должны зафиксировать требование - метод для смены темы должен быть внутри наших компонентов.
При этом мы заранее не можем знать, на какой "глубине/уровне вложенности" этот элемент управления темой может появится.
Кажется теперь, когда мы понимаем все требования к нашей системе, можно переходить к проектированию решения.
P.S.:
В рамках нашего скоупа обсуждения, мы не будем затрагивать вопросы всей дизайн системы, а сосредоточимся только на цветовых темах.
План действий
Примем решение о том, за счёт чего мы будем переключать цветовую тему
Определим где и как будем описывать связки цветов. Так же опишем механизм их применения в css
Сделаем тему доступной внутри react компонентов и определим, как именно мы дадим нашим компонентам возможность тригерить смену цветовой темы
Решения
Переключение темы
Решение:
data-theme атрибут для установки темы
Подробнее:
Переключать тему мы будем очень просто - изменяя содержимое data-theme атрибута на рутовом элементе (html).
document.documentElement.setAttribute('data-theme', theme);
Значением theme будет строка, являющаяся названием темы: dark | light;
Это максимально простое решение, которое требует минимум действий и так же позволяет нам опираться на каскад css для применения стилей конкретной темы на любом элементе нашей страницы.
Связки цветов
Решение:
триады для описание цвета
[data-theme='light'] слектор по аттрибуту и custom properties для применения в css.
Подробнее:
Цвета для каждой темы будем описывать триадами (сетами по 3 цифры, которые кодируют цвет).
Это удобно для того, чтобы иметь возможность применять прозрачность (устанавливать значение альфа канала) для цветов.
А для применения этих триад будем использовать custom properties. Они широко распространены и используются в том же CRA из коробки.
Т.е. одну и ту же переменную --some-color: 255, 255, 255 мы сможем использовать в двух вариантах: rgb и rgba
При этом нам надо иметь универсальный способ "доставки" значения цвета конкретной темы в тот кусочек css, который мы хотим стилизовать. Для этого будем использовать селектор по аттрибуту, который мы установили в предыдущем пункте, и внутри будем описывать уже переменные:
[data-theme='light'] { /* some vars here */ }
Итого, общая связка "объявить тему и сделать её доступной в css" будет выглядеть следующим образом:
:root { /* light theme tokens */ --background-primary-light: 44, 191, 170; --accent-light: 37, 89, 88; /* dark theme tokens */ --background-primary-dark: 25, 48, 66; --accent-dark: 90, 182, 204; } /* map tokens to proper theme */ [data-theme='light'] { --background-primary: var(--background-primary-light); --accent: var(--accent-light); } [data-theme='dark'] { --background-primary: var(--background-primary-dark); --accent: var(--accent-dark); }
А непосредственное применение значений этих перменных в css делается в 2 шага:
Один раз импортируем файл с токенами в рутовый компонент своего приложения:
import './components/theme-provider/themes.css';
Используем синтаксис custom properties для стилизации цветов:
.class { color: rgb(var(--accent)); background-color: rgba(var(--background-primary), 0.5); }
Таким образом реализованы базовые требования к нашей системе:
Возможность изменить стилизацию приложения (css) - просто используем наши custom properties
Возможность расширения - мы можем создать неограниченное большое количество цветовых тем.
Вот и всё, теперь мы имеем рабочий css механизм переключения цветовой темы. Это база, которая по сути не привязана ни к какому фреймворку/библиотеке и прочим вещам. Только css и его сборка.
Применение и изменение темы в react приложении.
Решение:
localStorage для сохранения значения темы
createContext react контекст, для получения доступа к теме и её изменению на любом уровне вложенности
useTheme кастомный хук, для упрощения доступа к контексту
Подробнее:
Тему надо бы уметь сохранять/получать между перезагрузкой страниц.
const StorageKey = 'features-color-theme'; const getTheme = (): Themes => { let theme = localStorage.getItem(StorageKey); if (!theme) { localStorage.setItem(StorageKey, 'light'); theme = 'light'; } return theme as Themes; };
Теперь объявим доступные темы и сам контекст:
const supportedThemes = { light: 'light', dark: 'dark', }; type Themes = keyof typeof supportedThemes; const ThemeContext = createContext< | { theme: Themes; setTheme: (theme: Themes) => void; supportedThemes: { [key: string]: string }; } | undefined >(undefined);
PS: Подробнее про keyof typeof
После этого мы должны создать сам компонент, который и будет "прокидывать" контекст вниз для всех своих children
const Theme = (props: { children: React.ReactNode }) => { const [theme, setTheme] = useState<Themes>(getTheme); return ( <ThemeContext.Provider value={{ theme, setTheme, supportedThemes, }} > {props.children} </ThemeContext.Provider> ); };
И не забудем написать кусочек кода, который сохраняет нам значение в нашем хранилище (localStorage)
const [theme, setTheme] = useState<Themes>(getTheme); useEffect(() => { localStorage.setItem(StorageKey, theme); document.documentElement.setAttribute('data-theme', theme); }, [theme]);
Вроде бы всё работает, осталось собрать всё воедино.
Однако перед этим давайте попробуем упростить себе жизнь в будущем и сделаем стандартный контрол, который будет изменять значение темы. Назовём его SimpleToggler.
function SimpleToggler() { const { theme, setTheme } = useTheme(); const handleSwitchTheme = () => { if (theme === 'dark') { setTheme('light'); } else { setTheme('dark'); } }; return ( <div className={Styles.simpleToggler} onClick={handleSwitchTheme}> <div className={Styles.ball} data-theme={theme} /> </div> ); }
:root { --toggler-padding: 3px; --toggler-border: 2px; --ball-diameter: 14px; --toggler-width: 47px; } .simpleToggler { display: flex; width: var(--toggler-width); border-radius: var(--toggler-width); padding: var(--toggler-padding); background-color: rgb(var(--background-primary)); border: var(--toggler-border) solid rgb(var(--accent)); display: flex; align-items: center; justify-content: space-between; box-sizing: border-box; position: relative; cursor: pointer; transition: backgroundColor 0.2s ease; } .ball { position: relative; z-index: 1; width: var(--ball-diameter); height: var(--ball-diameter); background-color: rgb(var(--background-primary)); background-position: center; background-size: cover; border-radius: 50%; transition: transform 0.2s linear, backgroundColor 0.2s ease; } .ball[data-theme='dark'] { background-image: url('./images/moon.png'); } .ball[data-theme='light'] { background-image: url('./images/sun.png'); } html[data-theme='light'] .simpleToggler { transform: translateX(0); } html[data-theme='dark'] .ball { transform: translateX( calc( var(--toggler-width) - var(--ball-diameter) - 4 * var(--toggler-padding) ) ); }
И для того, чтобы не "размазывать" логику по разным файлам мы применим подход Compound components.
Итак, собираем всё вместе:
import React, { useEffect, createContext, useState, useContext } from 'react'; import Styles from './index.module.css'; const StorageKey = 'features-color-theme'; const supportedThemes = { light: 'light', dark: 'dark', }; type Themes = keyof typeof supportedThemes; const ThemeContext = createContext< | { theme: Themes; setTheme: (theme: Themes) => void; supportedThemes: { [key: string]: string }; } | undefined >(undefined); const useTheme = () => { const context = useContext(ThemeContext); if (!context) { throw new Error( 'You can use "useTheme" hook only within a <ThemeProvider> component.' ); } return context; }; const getTheme = (): Themes => { let theme = localStorage.getItem(StorageKey); if (!theme) { localStorage.setItem(StorageKey, 'light'); theme = 'light'; } return theme as Themes; }; const Theme = (props: { children: React.ReactNode }) => { const [theme, setTheme] = useState<Themes>(getTheme); useEffect(() => { localStorage.setItem(StorageKey, theme); document.documentElement.setAttribute('data-theme', theme); }, [theme]); return ( <ThemeContext.Provider value={{ theme, setTheme, supportedThemes, }} > {props.children} </ThemeContext.Provider> ); }; Theme.SimpleToggler = function SimpleToggler() { const { theme, setTheme } = useTheme(); const handleSwitchTheme = () => { if (theme === 'dark') { setTheme('light'); } else { setTheme('dark'); } }; return ( <div className={Styles.simpleToggler} onClick={handleSwitchTheme}> <div className={Styles.ball} data-theme={theme} /> </div> ); }; export { useTheme }; export default Theme;
Применяем то, что получилось
Нам нужно обернуть в наш провайдер главный компонент приложения:
import React from 'react'; import ReactDOM from 'react-dom/client'; import Theme from './components/theme-provider'; import App from './pages'; import './index.css'; import './components/theme-provider/themes.css'; const root = document.getElementById('root') as HTMLElement; ReactDOM.createRoot(root).render( <Theme> <App /> </Theme> );
А в самих компонентах можем уже использовать то, что нам больше нравится и/или нужно:
Использовать готовый компонент simpleToggler
import Theme from './components/theme-provider'; import Styles from './index.module.css'; const Component = () => { return ( <div className={Styles.wrapper}> <Theme.SimpleToggler /> </div> ); };
Или использовать хук useTheme:
import { useTheme } from './components/theme-provider'; import Styles from './index.module.css'; const option_1 = '1'; const option_2 = '2'; const Component = () => { const { theme } = useTheme(); const value = theme === 'dark' ? option_1 : option_2; return ( <div className={Styles.wrapper}> {/* some markup that use selected theme value */} </div> ); };
Итого
Получили в итоге гибкую систему, которая отвечает всем изначально поставленным требованиям:
Имеем возможность расширения
описываем токены, собираем из них тему, используем
Имеем возможность изменять тему (css/js)
css переменные делают цвета темы доступной;
кастомный хук позволяет настраивать внешние компоненты по нашим потребностям
Имеем возможность настраивать тему откуда угодно
используем контекст для этого
Summary по решениям:
Переключение темы
data-theme аттрибут для установки темы
Связки цветов
триады для описание цвета
[data-theme='light'] слектор по аттрибуту и custom properties для применения в css.
Применение и изменение темы в react приложения.
localStorage для сохранения значения темы
createContext react контекст, для получения доступа к теме и её изменению на любом уровне вложенности
useTheme кастомный хук, для упрощения доступа к контексту
Спасибо за чтение и удачи в темизации ваших приложений)
PS:
Ссылки из статьи:
