Данный гайд описывает один из возможных подходов к организации фичи темизации приложения.

Глобально статья поделена на 2 части:

База

Здесь мы опишем логику работы с темами, которая построена на чистом css (ну почти). Но этот механизм не имеет никаких "завязок" на js фреймворк/библиотеку.

React

В этой части мы посмотрим, как именно в контексте react приложения мы сможем эффеективно применять наши темы.

Что хотим получить

Предет тем, как приступим к решение задачи темизации нашего react приложения сформулируем возможности, которые должна обладать наша будущая система.

Возможность расширения

Мы не должны быть ограничены только одной цветовой темой. И нам не должно быть мучительно больно, если наши старшие братья по интерфейсам (дизайнеры) вдруг запилят к стандартным "светлой" и "тёмной" темам ещё и "малиновую, по случаю дня рождения компании".

Возможность изменить стилизацию приложения (css).

Это в общем то и есть основная цель - мы должны уметь легко применять цвета выбранной темы на наши элементы в css.

Возможность изменить стилизацию приложения (js)

Части наших элементов, может понадобится изменять своё отображение не только на основе css но и на основе свойств, которые мы пробрасываем внутрь компонентов. Например, так работают большинство библиотек элементов (закрыты к изменению, но имеют api для управления).

Следовательно где-то в коде уже наших компонентов мы должны иметь возможность получить значение текущей темы и работать с ним.

Возможность из любого места приложения инициировать изменение темы

В любой момент развития нашего проекта/продукта может изменится дизайн концепция и/или могут тестироваться различные гипотезы, которые могут привести к изменению нашего ui. Компоненты могут меняться местами, элементы управления уезжать "вглубь" или в порталы/модальные окна и т.д.
Для того, чтобы в реализации учесть эти возможные изменения мы должны зафиксировать требование - метод для смены темы должен быть внутри наших компонентов.

При этом мы заранее не можем знать, на какой "глубине/уровне вложенности" этот элемент управления темой может появится.

Кажется теперь, когда мы понимаем все требования к нашей системе, можно переходить к проектированию решения.

P.S.:
В рамках нашего скоупа обсуждения, мы не будем затрагивать вопросы всей дизайн системы, а сосредоточимся только на цветовых темах.

План действий

  1. Примем решение о том, за счёт чего мы будем переключать цветовую тему

  2. Определим где и как будем описывать связки цветов. Так же опишем механизм их применения в css

  3. Сделаем тему доступной внутри 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 шага:

  1. Один раз импортируем файл с токенами в рутовый компонент своего приложения:

import './components/theme-provider/themes.css';
  1. Используем синтаксис 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>
);

А в самих компонентах можем уже использовать то, что нам больше нравится и/или нужно:

  1. Использовать готовый компонент simpleToggler

import Theme from './components/theme-provider';
import Styles from './index.module.css';

const Component = () => {
  return (
    <div className={Styles.wrapper}>
      <Theme.SimpleToggler />
    </div>
  );
};
  1. Или использовать хук 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:
Ссылки из статьи:

Комментарии (1)