[velog 클론 코딩 개발기 -5 ] 다크 모드 (2)

다크/라이트모드 별 스타일 정의

다크/라이트 모드 별 스타일 정의하기

interface ThemeVariables {
  bg_page1: string;
  bg_page2: string;
}

const lightThemeVariables: ThemeVariables = {
  bg_page1: '#F8F9FA',
  bg_page2: '#FFFFFF',
}

const darkThemeVariables: ThemeVariables = {
  bg_page1: '#121212',
  bg_page2: '#121212',
}

ThemeVariables 라는 type 으로 lightThemeVariables, darkThemeVariables 2개의 객체를 만들었다. 이 객체를 가공하여 css variable 을 만들고 컴포넌트에서 참조할 수 있는 구조로 만들 것이다.

먼저 css variable 로 만들기 위해 객체의 모든 key, value 를 1개의 string 으로 만들었다.

// {bg_page1: '#F8F9FA',bg_page2: '#FFFFFF'} 
//-> --bg-page1: '#F8F9FA'; 
//   --bg-page2: '#FFFFFF';

export const lightTheme: string = (
  Object.keys(lightThemeVariables) as Array<keyof ThemeVariables>
).reduce((acc, key) => {
  return acc.concat(`--${key.replace(/_/g, '-')}: ${lightThemeVariables[key]};`, '\n');
}, '');

export const darkTheme: string = (
  Object.keys(darkThemeVariables) as Array<keyof ThemeVariables>
).reduce((acc, key) => {
  return acc.concat(`--${key.replace(/_/g, '-')}: ${darkThemeVariables[key]};`, '\n');
}, '');

그리고 컴포넌트에서 theme.bg_page1 처럼 작성하면 var(--bg-page1) 를 사용할 수 있게끔 theme 객체를 만들었다.

const cssVar = (name: string) => `var(--${name.replace(/_/g, '-')})`;

const theme = (Object.keys(lightThemeVariables) as Array<keyof ThemeVariables>).reduce(
  (acc, current) => {
    acc[current] = cssVar(current);
    return acc;
  },
  {} as ThemeVariables
);
// {bg_page1: 'var(--bg-page1)',bg_page2: 'var(--bg-page2)'}

GlobalStyles 를 통해 스타일 적용하기

위에서 만든 css variable로 등록해줄 string 을 import 해와 GlobalStyles 에서 적용해주었다.

import { Interpolation, Theme, css } from '@emotion/react';
import { darkTheme, lightTheme } from './Theme';

const globalStyles: Interpolation<Theme> = css`
  body {
    ${lightTheme}
  }

  @media (prefers-color-scheme: dark) {
    body {
      ${darkTheme}
    }
  }

  body[data-theme='light'] {
    ${lightTheme}
  }

  body[data-theme='dark'] {
    ${darkTheme}
  }
`;

export default globalStyles;

다크/라이트 모드 적용

Redux 에서 theme state 관리

import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import type { RootState } from '../store';

type ThemeMode = 'dark' | 'light' | 'default';

export interface ThemeModeState {
  userThemeMode: ThemeMode;
  systemThemeMode: ThemeMode;
}

const initialState: ThemeModeState = {
  userThemeMode: 'default',
  systemThemeMode: 'default',
};

export const ThemeSlice = createSlice({
  name: 'darkMode',
  initialState,
  reducers: {
    enableDarkMode(state) {
      state.userThemeMode = 'dark';
    },
    enableLightMode(state) {
      state.userThemeMode = 'light';
    },
    setSystemThemeMode(state, action: PayloadAction<'dark' | 'light'>) {
      state.systemThemeMode = action.payload;
    },
  },
});

export const { enableDarkMode, enableLightMode, setSystemThemeMode } = ThemeSlice.actions;

export const themeState = (state: RootState) => state.themeState;

export default ThemeSlice.reducer;


//store.ts
export const store = configureStore({
  reducer: {
    //... 기존 코드 중략
    themeState: ThemeReducer,
  },
});

사용자가 설정한 테마와 시스템 테마를 각각 관리하기 위한 state 를 두고 초기값을 default 로 설정해주었다. 사용자가 설정한 테마가 없을 경우 시스템 테마를 사용한다. 시스템 테마는 처음에 클라이언트가 렌더링 될 때 감지해서 업데이트 해줄것이다.

ThemeToggleButton 구현

svg 를 컴포넌트처럼 사용하기

@svgr/webpack 패키지를 설치해 svg 파일을 React component 처럼 import 해서 사용할 수 있다.

npm i -D @svgr/webpack 를 통해 해당 패키지를 설치하고 webpack 설정 파일에서 loader 를 설정해주어야한다.

//webpack.common.js
{
   test: /\.svg$/i,
   use: ['@svgr/webpack'],
}

이렇게 설정하여 주면 아래 코드처럼 svg 파일을 React Component 처럼 import 해와서 사용할 수 있다.

import React from 'react';
import MoonIcon from 'assets/icon-moon.svg';
import SunIcon from 'assets/icon-sun.svg';

const ThemeToggleButton = () => {
  const themeMode = 'dark';
  return (
    <button>
      {themeMode === 'dark' ? <MoonIcon /> : <SunIcon />}
    </button>
  );
};

export default ThemeToggleButton;

만약 jest 를 사용하고 있을 경우 위 코드의 2번째 줄에서 SyntaxError: Unexpected token '<' error 가 발생하기 때문에 mock 을 만들어서 moduleNameMapper 에 등록해주어야한다.

//svgrMock.js
export default 'SvgrURL';
export const ReactComponent = 'div';

//jest.config.js
module.exports = {
    // 기존 코드 중략
    '\\.svg$': '<rootDir>/__mocks__/svgrMock.js',
  },
};

기능 구현

const ThemeToggleButton = () => {
  const dispatch = useAppDispatch();
  const storeThemeMode = useAppSelector(state => state.themeState);

  const getThemeMode = () => {
    if (storeThemeMode.systemThemeMode === 'default') {
      return 'light';
    }
    if (storeThemeMode.userThemeMode !== 'default') {
      return storeThemeMode.userThemeMode;
    }
    return storeThemeMode.systemThemeMode;
  };

  const themeMode = getThemeMode();

  const toggleThemeMode = () => {
    if (themeMode === 'dark') {
      dispatch(enableLightMode());
      localStorage.setItem('theme', 'light');
      return;
    }
    dispatch(enableDarkMode());
    localStorage.setItem('theme', 'dark');
  };

  return (
    <button
      onClick={toggleThemeMode}
    >
      {themeMode === 'dark' ? <MoonIcon /> : <SunIcon />}
    </button>
  );
};

export default ThemeToggleButton;

시스템 설정 테마가 지정되지 않았을 경우 라이트 모드로 보여주고, 사용자 설정 테마가 지정되어 있을 경우 사용자 설정 테마로 보여준다.
시스템 설정 테마는 지정되어 있고 사용자 설정 테마는 지정되어 있지 않을 경우 시스템 설정 테마로 보여준다.

또한 사용자가 재접속 했을때 사용자 설정 테마를 유지하기 위해 localStorage 에 담아둔다.

또한 store 의 useThemeMode state 의 변화에 따라 body[data-theme] 속성을 업데이트해주어 다른 css variables 로 갈아끼워준다.

  useEffect(() => {
    if (themeMode.userThemeMode !== 'default') {
      document.body.dataset.theme = themeMode.userThemeMode;
    }
  }, [themeMode]);

사이트에 재접속한 사용자 선호 테마 기억하기

// App.tsx
  const loadTheme = useCallback(() => {
    const theme = localStorage.getItem('theme');
    if (!theme) return;
    if (theme === 'dark') {
      dispatch(enableDarkMode());
    } else {
      dispatch(enableLightMode());
    }
    document.body.dataset.theme = theme;
  }, [dispatch]);

  useEffect(() => {
    loadTheme();
  }, [loadTheme]);

localStorage 에 담아두었던 theme 정보로 store 의 userThemeMode state 를 업데이트해준다.

시스템 테마 감지해서 state 업데이트 하기

// App.tsx
  useEffect(() => {
    const darkMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

    const systemPrefersDark = darkMediaQuery.matches;
    dispatch(setSystemThemeMode(systemPrefersDark ? 'dark' : 'light'));

    darkMediaQuery.addEventListener('change', e => {
      dispatch(setSystemThemeMode(e.matches ? 'dark' : 'light'));
    });
  }, [dispatch]);

App.tsx 파일에 코드를 작성해 처음 렌더링 되었을때 코드가 실행되도록 하였다. ('(prefers-color-scheme: dark)').matches 를 사용해 시스템 설정 테마를 감지하고 store 의 systemThemeMode 를 업데이트 해준다.

또한 미디어쿼리에 이벤트리스너를 달아주어 시스템 다크 모드 설정 변경시 systhemThemeMode state 업데이트해주도록 하였다.

좋은 웹페이지 즐겨찾기