В этой статье будет пошаговое руководство по созданию пользовательских тем для проекта Next.js с использованием Material-UI и TypeScript. Мы расскажем, как настроить новый проект, установить и настроить Material-UI и создать несколько пользовательских тем, которые можно легко повторно использовать в проекте. Кроме того, мы узнаем, как правильно создать тему с помощью TypeScript и сделать ее доступной по всему миру. К концу этой статьи читатели будут иметь четкое представление о том, как создавать собственные темы в проекте Next.js с использованием Material-UI и TypeScript.

  1. Создать проект Next.js
npx create-next-app@latest --typescript

2. Установите библиотеку пользовательского интерфейса материалов.

npm install @mui/material

3.Теперь давайте поговорим об основной теме: пользовательской теме.

Чтобы упростить это руководство, давайте создадим простой проект с некоторыми пользовательскими темами.

"Где сохранить состояние темы?"

Очевидно, вам придется где-то сохранить текущую тему, но где будет наиболее подходящим местом для сохранения этой информации?

Для этого есть некоторые варианты.

  1. Первый вариант — сохранить его в памяти с помощью React Hook, такого как useState, но это будет означать, что при обновлении страницы состояние будет сброшено, и вы потеряете состояние темы. Это, вероятно, то, что может в конечном итоге очень раздражать пользователя и определенно то, что вы не хотите использовать.
  2. Вы также можете сохранить эту информацию на стороне сервера, создав сеанс пользователя, и сделать запрос на получение текущей темы.
  3. Точно так же мы можем сохранить состояние темы на LocalStorage, так как эта информация не важна и может быть потеряна без проблем.

Чтобы немного упростить это руководство, мы воспользуемся третьим способом.

Для этого мы рекомендуем вам создать собственный хук.

const [config, setConfig] = useLocalStorage("config", defaultConfig);

Чтобы реализовать это useLocalStorage, вы можете сделать что-то вроде этого:

import {useEffect, useState} from "react";

const useLocalStorage = <T>(key: string, defaultValue: T): [T, (value: T) => void] => {
  const [value, setValue] = useState<T>(() => {
    try {
      return JSON.parse(window.localStorage.getItem(key)) ?? defaultValue;
    } catch (error) {
      return defaultValue;
    }
  });

  useEffect(() => {
    const rawValue = JSON.stringify(value);
    localStorage.setItem(key, rawValue);
  }, [key, value]);

  return [value, setValue];
};


export {useLocalStorage}

Этот пользовательский хук помогает управлять данными в LocalStorage. Он принимает ключ и аргумент defaultValue и использует хуки useState и useEffect для создания переменной состояния, которая содержит текущее значение данных в локальном хранилище. Всякий раз, когда ключ или значение изменяются, хук обновляет данные в локальном хранилище, чтобы синхронизировать их с компонентом.

Приложение дополнительных усилий для создания положительного пользовательского опыта.

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

Чтобы решить эту проблему, вы можете включить код из этого пользовательского хука, который заменяет предыдущий хук useEffect:

const useLocalStorage = <T>(key: string, defaultValue: T): [T, (value: T) => void] => {
  const [value, setValue] = useState<T>(() => {
    try {
      return JSON.parse(window.localStorage.getItem(key)) ?? defaultValue;
    } catch (error) {
      return defaultValue;
    }
  });

  const updateValue = useCallback((newValue: T) => {
    try {
      setValue(newValue);
      window.localStorage.setItem(key, JSON.stringify(newValue));
    } catch (error) {
      console.error(`Error setting localStorage item: ${error}`);
    }
  }, [key]);

  useEffect(() => {
    const handleStorageChange = (event: StorageEvent) => {
      if (event.key === key) {
        try {
          const newValue = JSON.parse(event.newValue!) ?? defaultValue;
          setValue(newValue);
        } catch (error) {
          console.error(`Error parsing localStorage value: ${error}`);
        }
      }
    };

    window.addEventListener("storage", handleStorageChange);

    return () => {
      window.removeEventListener("storage", handleStorageChange)
    };
  }, [defaultValue, key]);

  return [value, updateValue];
};

export {useLocalStorage};

Отлично, у нас уже есть место для сохранения этой информации, но это приводит нас к вопросу:

Хотим ли мы использовать этот настраиваемый хук каждый раз, когда нам нужно получить доступ к состоянию темы?

На этот вопрос нет правильного ответа — это зависит.

Если состояние темы, которое вы сохраняете в LocalStorage, часто читается и требует обновления в нескольких компонентах, может быть более эффективным хранить его в контексте, особенно если обновления происходят часто. Это позволит избежать накладных расходов на чтение и анализ значения из LocalStorage каждый раз, когда это необходимо.

В общем, решать вам, но чтобы сделать это руководство более полным, мы создадим контекст, чтобы лучше понять, как его реализовать.

Что ж… У вас уже есть способ сохранить тему, но вам потребуется доступ к любой части вашего приложения. Для этого вам понадобится состояние приложения-менеджера, и, поскольку эта демонстрация будет очень простой, мы будем использовать контекст из React, но вы всегда можете использовать другой — это мало что изменит.

Хук useApplicationConfigs определен для извлечения конфигураций приложения из контекста.

Компонент ApplicationConfigs использует хук useLocalStorage для хранения и извлечения параметров конфигурации и предоставляет changeTheme для обновления настроек темы.

interface ApplicationConfigsProps {
  children: JSX.Element;
}

interface ApplicationConfigsContextInterface {
  changeTheme: () => void;
  theme: Theme;
}

export const defaultApplicationConfigsContext: ApplicationConfigsContextInterface = {
  theme: THEME.LIGHT,
  changeTheme: (theme: Theme) => {}
};

export const ApplicationConfigsContext = createContext(defaultApplicationConfigsContext);

const useApplicationConfigs = () => useContext(ApplicationConfigsContext);

const ApplicationConfigs = ({children}: ApplicationConfigsProps) => {

  const defaultConfig = {
    theme: THEME.LIGHT
  };

  const [config, setConfig] = useLocalStorage("config", defaultConfig);

 const changeTheme = useCallback(
    (theme: Theme) => {
      setConfig({...config, theme})
    },[config.theme]);


  return (
    <ApplicationConfigsContext.Provider
      value={{
        theme: config.theme,
        changeTheme,
      }}
    >
      {children}
    </ApplicationConfigsContext.Provider>
  );
};

export {ApplicationConfigs, useApplicationConfigs};

а также оберните свое приложение с помощью ‹ApplicationConfigs/› в _app.tsx.

function App({Component, pageProps}: AppProps) {

  return (
    <ApplicationConfigs>
        <Component {...pageProps} />
    </ApplicationConfigs>
  )
}

В целом, этот код обеспечивает удобный способ управления, сохранения настроек конфигурации приложения и предоставления доступа к ним любому компоненту, которому они нужны, через контекст.

Теперь вам может быть интересно, как получить доступ к теме из любого компонента. Чтобы добиться этого, вы можете сделать что-то вроде этого:

const {changeTheme, theme} = useApplicationConfigs()

Перейдем к самому интересному :)

  1. В корне проекта вы должны создать папку «theme».

Внутри этой папки создайте файл с именем constants.ts, и туда вы поместите столько тем, сколько пожелаете. Сейчас мы будем использовать только две темы: светлый режим и темный режим, чтобы упростить задачу.

export enum THEME {
  'LIGHT' = 'light',
  'DARK' = 'dark',
}

И вы также можете добавить этот интерфейс:

export interface ThemeConfig {
  theme?: string;
}

Внутри этой папки темы также создайте index.ts

Там вы можете определить некоторые основные параметры, то есть вещи, которые должны применяться независимо от того, в каком режиме вы находитесь.

Давайте представим, что вы хотите, чтобы к вашему веб-сайту применялось определенное семейство шрифтов:

import {ThemeOptions} from "@mui/material";

export const baseOptions: ThemeOptions = {
  typography: {  
    fontFamily:'-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"',
  }
}

Свойства внутри объекта ThemeConfig могут различаться в зависимости от используемой вами версии Material-UI, но некоторые из наиболее часто используемых свойств включают:

  1. Компоненты:

Компоненты пользовательского интерфейса материалов, которые вы, возможно, захотите отредактировать

2. Палитра:

Когда мы используем пользовательский интерфейс материала, у вас есть некоторые параметры палитры цветов, такие как основной, дополнительный, предупреждение и т. д. Вы можете переопределить их все здесь. и используйте собственную цветовую палитру.

3. Форма:

Вы также можете определить форму различных компонентов Material-UI, таких как округлость кнопок и карточек.

4. Типографика:

Определяет стили типографики, используемые темой, включая семейство шрифтов, размер шрифта, высоту строки и многое другое.

Дополнительную информацию см. в документации по пользовательскому интерфейсу материалов.

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

Для этого вы можете создать объект типа Record‹string, ThemeOptions›.

export const themesOptions: Record<string, ThemeOptions> = {
  [THEME.LIGHT]: {
  
  
  }
  [THEME.DARK]: {
  
  }
}

Внутри [THEME.LIGHT] и [THEME.DARK] вы можете создавать различные стили, которые будут применяться к вашему приложению. Есть много вещей, которые вы можете изменить, как мы уже демонстрировали ранее.

Начнем с простого:

export const themesOptions: Record<string, ThemeOptions> = {

const __color_primary_500 = '#f4f5f7'
const __color_neutral_500 = '#FFFFFF'
const __color_text_500 = '#121212'

const __dark_color_primary_500 = '#1E1E1E'
const __dark_color_neutral_500 = '#1E1E1E'
const __dark_color_text_500 = '#FFFFFF'

  [THEME.LIGHT]: {
       components: {
         MuiButton: {
            styleOverrides: {
                root: {
                  backgroundColor: __color_primary,
                  color: __color_text
                },
            },
          MuiCard: {
                  styleOverrides: {
                    root: {
                      backgroundColor: __color_neutral,
                      color: __color_text
                    }
                  }
          }
        }
      }
  
  },
  [THEME.DARK]: {
       components: {
         MuiButton: {
            styleOverrides: {
                root: {
                  backgroundColor: __dark_color_primary,
                  color: __dark_color_text
                },
            },
        }
      },
       MuiCard: {
            styleOverrides: {
              root: {
                backgroundColor: __dark_color_neutral,
                color: __dark_color_text,
              }
            }
      }
  }
}

Соглашения об именах цветов

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

  • Основной. Основной цвет обычно является наиболее заметным в вашей теме и часто используется для кнопок, ссылок и других интерактивных элементов. Это должен быть смелый и отчетливый цвет, который выделяется на фоне.
  • Дополнительный. Дополнительный цвет часто используется для дополнения основного цвета и создания дополнительного визуального интереса. Это должен быть цвет, хорошо контрастирующий с основным цветом, но не настолько смелый, чтобы конкурировать с ним.
  • Фон. Цвет фона используется для задания тона теме и основного цвета всего дизайна. Это должен быть нейтральный цвет, который не отвлекает слишком много внимания от других элементов темы.
  • Текст.Цвет текста используется для обеспечения контраста с фоном и обеспечения легкости чтения текста. Это должен быть цвет, приятный для глаз и хорошо контрастирующий с цветом фона.
  • Кроме того, Успех, Предупреждение, Ошибка, Нейтрально.

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

Для этих вариантов я предлагаю использовать следующее соглашение об именах:

  • Используйте имя «500» для базы (по умолчанию).
  • Используйте названия «400», «300», «200» и «100» для более светлых тонов.
  • Используйте большие числа, такие как «600» и «700», для более темных оттенков.

Вы также можете использовать те же теги, что и "темный", чтобы быть более заметным для разработчиков.

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

Теперь мы хотим увидеть применение этой разницы. Для этого нам нужно создать кнопку/карту пользовательского интерфейса материала, но для этого нам нужно будет обернуть все наши приложения поставщиком, предоставленным пользовательским интерфейсом материала.

Поскольку мы хотим использовать разные темы, мы не должны делать «обычную» настройку пользовательского интерфейса материалов. Для этого мы начнем с создания нашего собственного провайдера.

interface ApplicationConfigsContextInterface {
  theme: Theme;
}

export const defaultThemeContext: ApplicationConfigsContextInterface = {
  theme: THEME.LIGHT
};

export const ThemeContext = createContext(defaultThemeContext);

export const ThemeContextProvider = (props: { children: JSX.Element }) => {

  const defaultConfig = {
    THEME: 'light'
  }
  const [config,] = useLocalStorage("config", defaultConfig);

  const darkTheme = createCustomTheme({
    theme: THEME.DARK,
  });

  const lightTheme = createCustomTheme({
    theme: THEME.LIGHT,
  });

  return (
    <ThemeContext.Provider value={config}>
      <ThemeProvider theme={config.theme === 'light' ? lightTheme : darkTheme}>{props.children}</ThemeProvider>
    </ThemeContext.Provider>
  );
};

Затем вы должны добавить свой собственный ThemeContextProvider в _app.tsx. Что-то вроде этого:

function App({Component, pageProps}: AppProps) {

  return (
    <ApplicationConfigs>
      <ThemeContextProvider>
        <Component {...pageProps} />
      </ThemeContextProvider>
    </ApplicationConfigs>
   )
}

export default App

Теперь давайте создадим две кнопки!

Для этого вы можете начать с добавления кнопки Material UI в свой проект:

import {Button} from "@mui/material";

export default function Home() {

  const {changeTheme} = useApplicationConfigs()

  return (
    <article>
       <Button onClick={() => changeTheme(THEME.DARK)}>Dark</Button>
       <Button onClick={() => changeTheme(THEME.LIGHT)}>Light</Button>
        <Card>
          <CardContent>
            <Typography variant="h5">
              Lizard
            </Typography>
            <Typography variant="body2">
              Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the
              industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and
              scrambled it to make a type specimen book.
            </Typography>
          </CardContent>
        </Card>
    </article>
  )
}

В этот момент вы должны иметь возможность переключаться между различными темами, нажимая на кнопки!

Самое сложное сделано! Поздравляем!

Реализация обнаружения системной темы

Конечная цель разработчиков — обеспечить пользователям положительный опыт работы с их продуктом. В идеале пользователям не нужно вручную выбирать тему, а тема по умолчанию должна соответствовать их предпочтениям. Одним из эффективных подходов для достижения этой цели является использование системной темы.

предпочитает цветовую схему

Это мультимедийная функция CSS, которая позволяет вам ориентироваться на пользователей на основе их предпочтительной цветовой схемы. Эта функция определяет, установил ли пользователь свою операционную систему или устройство, и это именно то, что мы будем использовать.

Но перед этим добавим тип system:

export enum THEME {
  'LIGHT' = 'light',
  'DARK' = 'dark',
  'SYSTEM' = 'system',
}

export type Theme = THEME.DARK | THEME.LIGHT | THEME.SYSTEM

Вам также потребуется обновить контекст ApplicationConfigs. Для начала вам нужно будет сохранить состояние, чтобы узнать, изменила ли система тему.

Для этого вы можете начать с добавления следующего:

....

const [systemChanged, setSystemChanged] = useState();

....
...
const MEDIA = '(prefers-color-scheme: dark)'

const handleMediaQuery = useCallback(() => {

    if (config.theme === THEME.SYSTEM) {
      setSystemChanged(prevState => !prevState);
    }
  },
  [config.theme]
)

// Always listen to System preference
useEffect(() => {
  const media = window.matchMedia(MEDIA)

  // Intentionally use deprecated methods to support iOS and old browsers
  media.addListener(handleMediaQuery)

  return () => media.removeListener(handleMediaQuery)
}, [handleMediaQuery])

...

Мы придумали решение, которое всегда прислушивается к системным предпочтениям, обеспечивая наилучшее взаимодействие с пользователем. С этим решением вам не нужно будет обновлять страницу, если вы измените системную тему, так как она будет обновляться автоматически.

Не забудьте также передать состояние systemChanged вашему провайдеру!

<ApplicationConfigsContext.Provider
  value={{
    theme: config.theme,
    changeTheme,
    systemChanged
  }}
>
  {children}
</ApplicationConfigsContext.Provider>

Вам также потребуется обновить defaultApplicationConfigsContext и ApplicationConfigsContextInterface.

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

const MEDIA = '(prefers-color-scheme: dark)'

export const getSystemTheme = (e?: MediaQueryList | MediaQueryListEvent) => {
  if (!e) e = window.matchMedia(MEDIA)
  const isDark = e.matches
  return isDark ? THEME.DARK : THEME.LIGHT
}

Мы почти у цели — нужен только последний шаг, чтобы все сделать.

Перейдите к ThemeContextProvider и обновите его:

interface ApplicationConfigsContextInterface {
  theme: Theme;
}

export const defaultThemeContext: ApplicationConfigsContextInterface = {
  theme: THEME.LIGHT
};

export const ThemeContext = createContext(defaultThemeContext);

export const ThemeContextProvider = (props: { children: JSX.Element }) => {

  const {theme, systemChanged} = useApplicationConfigs();

  const darkTheme = createCustomTheme({
    theme: THEME.DARK,
  });

  const lightTheme = createCustomTheme({
    theme: THEME.LIGHT,
  });

  const [themeResolved, setThemeResolved] = useState<ThemeMaterialUI>(lightTheme);

  useEffect(() => {
    if (theme === THEME.SYSTEM) {
      const themeResolved = getSystemTheme();
      if (themeResolved === THEME.DARK) {
        setThemeResolved(darkTheme)
      } else {
        setThemeResolved(lightTheme);
      }
      document.documentElement.style.colorScheme = themeResolved
    } else {
      theme === THEME.DARK ? setThemeResolved(darkTheme) : setThemeResolved(lightTheme);
      document.documentElement.style.colorScheme = theme
    }
  }, [theme, systemChanged])


  return (
    <ThemeContext.Provider value={{theme}}>
      <ThemeProvider theme={themeResolved}>{props.children}</ThemeProvider>
    </ThemeContext.Provider>
  );
};

Поздравляем, теперь у вас есть все!

Репозиторий Github: https://github.com/micabaptista/Complete-Guide-To-Create-Custom-Themes-with-Material-UI-and-Next.js