Skip to content

Commit 12fa5c3

Browse files
committed
Implement theme switcher
1 parent 8800b76 commit 12fa5c3

File tree

3 files changed

+131
-43
lines changed

3 files changed

+131
-43
lines changed

src/App.tsx

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { mdiFormatListChecks, mdiMagnify } from '@mdi/js'
2-
import React, { FC, useEffect, useState } from 'react'
1+
import { mdiFormatListChecks, mdiMagnify, mdiThemeLightDark, mdiWeatherNight, mdiWeatherSunny } from '@mdi/js'
2+
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'
33
import { AppAction } from './components/AppAction'
44
import { AppActions } from './components/AppActions'
55
import { AppContent } from './components/AppContent'
@@ -11,19 +11,26 @@ import { FooterText } from './components/FooterText'
1111
import { LinkList } from './components/LinkList'
1212
import { Search } from './components/Search'
1313
import { links } from './links'
14-
import { setMode, toggleMode, useCurrentMode, AppMode } from './stores/currentModeStore'
14+
import { AppMode, setMode, toggleMode, useCurrentMode } from './stores/currentModeStore'
1515
import { HiddenLinks, useHiddenLinks } from './stores/hiddenLinksStore'
16+
import { setThemeStateSetting, getThemeStateSetting } from './services/localStorageService'
1617

1718
export const WebdevHome: FC = () => {
18-
const { handleCustomizeAction, hiddenLinks } = useCustomizeFeature()
19-
const { handleSearchAction, latestKeypress } = useSearchFeature()
2019
const { mode } = useCurrentMode()
20+
const { handleCustomizeAction, hiddenLinks } = useCustomizeMode()
21+
const { handleSearchAction, latestKeypress } = useSearchMode()
22+
const { themeSwitcherIcon, handleThemeSwitcherAction } = useThemeSwitcher()
2123

2224
return (
2325
<div className="app">
2426
<AppHeader />
2527

2628
<AppActions>
29+
<AppAction
30+
icon={themeSwitcherIcon}
31+
action={handleThemeSwitcherAction}
32+
active={false}
33+
/>
2734
<AppAction
2835
icon={mdiMagnify}
2936
action={handleSearchAction}
@@ -68,12 +75,12 @@ export const WebdevHome: FC = () => {
6875
}
6976

7077
// #region customize feature
71-
interface CustomizeFeature {
78+
interface UseCustomizeModeReturn {
7279
hiddenLinks: HiddenLinks
7380
handleCustomizeAction: () => void
7481
}
7582

76-
function useCustomizeFeature (): CustomizeFeature {
83+
function useCustomizeMode (): UseCustomizeModeReturn {
7784
const hiddenLinks = useHiddenLinks()
7885
const { mode } = useCurrentMode()
7986

@@ -100,12 +107,12 @@ function useCustomizeFeature (): CustomizeFeature {
100107
// #endregion customize feature
101108

102109
// #region search feature
103-
interface SearchFeature {
110+
interface UseSearchModeReturn {
104111
handleSearchAction: () => void
105112
latestKeypress: string
106113
}
107114

108-
function useSearchFeature (): SearchFeature {
115+
function useSearchMode (): UseSearchModeReturn {
109116
const [latestKeypress, setLatestKeypress] = useState<string>('')
110117
const { mode } = useCurrentMode()
111118

@@ -133,3 +140,55 @@ function useSearchFeature (): SearchFeature {
133140
return { handleSearchAction, latestKeypress }
134141
}
135142
// #endregion search feature
143+
144+
// #region theme switcher
145+
export const themeStates = ['auto', 'light', 'dark'] as const
146+
export type ThemeState = typeof themeStates[number]
147+
148+
interface UseThemeSwitcherReturn {
149+
themeSwitcherIcon: string
150+
handleThemeSwitcherAction: () => void
151+
}
152+
153+
function useThemeSwitcher (): UseThemeSwitcherReturn {
154+
const bodyElement = globalThis.document.getElementsByTagName('body')[0]
155+
const [themeState, setThemeState] =
156+
useState<ThemeState>(getThemeStateSetting())
157+
158+
useEffect(
159+
() => {
160+
setThemeStateSetting(themeState)
161+
bodyElement.className = `${themeState}-theme`
162+
},
163+
[bodyElement.className, themeState]
164+
)
165+
166+
const themeSwitcherIcon = useMemo(
167+
(): string => {
168+
if (themeState === 'light') { return mdiWeatherSunny }
169+
if (themeState === 'dark') { return mdiWeatherNight }
170+
return mdiThemeLightDark
171+
},
172+
[themeState]
173+
)
174+
175+
const handleThemeSwitcherAction = useCallback(
176+
(): void => {
177+
switch (themeState) {
178+
case 'light':
179+
setThemeState('dark')
180+
break
181+
case 'dark':
182+
setThemeState('auto')
183+
break
184+
default:
185+
setThemeState('light')
186+
break
187+
}
188+
},
189+
[themeState]
190+
)
191+
192+
return { themeSwitcherIcon, handleThemeSwitcherAction }
193+
}
194+
// #endregion theme switcher

src/sass/themes.scss

Lines changed: 40 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
:root {
1+
@mixin lightTheme () {
22
--primary-color-hue: 190;
33
--page-background: hsl(0, 0%, 100%);
44
--icon-color: hsl(0, 0%, 0%);
@@ -28,36 +28,43 @@
2828
--footer-link-background-hover: hsl(0, 0%, 88%);
2929
--footer-background: hsl(0, 0%, 95%);
3030
--footer-border: hsl(0, 0%, 70%);
31+
}
3132

32-
@media (prefers-color-scheme: dark) {
33-
--primary-color-hue: 190;
34-
--page-background: hsl(0, 0%, 20%);
35-
--icon-color: hsl(0, 0%, 100%);
36-
--icon-color-active: hsl(0, 0%, 0%);
37-
--icon-focus-border: hsl(var(--primary-color-hue), 50%, 60%);
38-
--icon-background: hsl(0, 0%, 100%);
39-
--primary-color: hsl(var(--primary-color-hue), 50%, 80%);
40-
--primary-color-pale: hsl(var(--primary-color-hue), 50%, 55%);
41-
--primary-color-dark: hsl(var(--primary-color-hue), 50%, 55%);
42-
--primary-color-hover: hsl(0, 0%, 30%);
43-
--action-color: hsl(var(--primary-color-hue), 50%, 60%);
44-
--action-color-inactive: hsl(var(--primary-color-hue), 50%, 30%);
45-
--link-item-background: hsl(0, 0%, 40%);
46-
--link-item-background-hover: hsl(0, 0%, 35%);
47-
--link-item-color: hsl(var(--primary-color-hue), 0%, 90%);
48-
--link-item-color-hover: hsl(var(--primary-color-hue), 0%, 100%);
49-
--link-item-focus-border: hsl(0, 0%, 50%);
50-
--input-background: hsl(0, 0%, 40%);
51-
--input-text-color: hsl(0, 0%, 95%);
52-
--input-placeholder-color: hsl(0, 0%, 70%);
53-
--logo-char-1-color: hsl(var(--primary-color-hue), 60%, 55%);
54-
--logo-char-2-color: hsl(var(--primary-color-hue), 60%, 80%);
55-
--logo-shape-color: hsl(var(--primary-color-hue), 60%, 55%);
56-
--footer-text-color: hsl(0, 0%, 80%);
57-
--footer-link-color: var(--primary-color-pale);
58-
--footer-link-color-hover: hsl(var(--primary-color-hue), 50%, 80%);
59-
--footer-link-background-hover: hsl(0, 0%, 35%);
60-
--footer-background: hsl(0, 0%, 30%);
61-
--footer-border: hsl(0, 0%, 40%);
62-
}
63-
}
33+
@mixin darkTheme () {
34+
--primary-color-hue: 190;
35+
--page-background: hsl(0, 0%, 20%);
36+
--icon-color: hsl(0, 0%, 100%);
37+
--icon-color-active: hsl(0, 0%, 0%);
38+
--icon-focus-border: hsl(var(--primary-color-hue), 50%, 60%);
39+
--icon-background: hsl(0, 0%, 100%);
40+
--primary-color: hsl(var(--primary-color-hue), 50%, 80%);
41+
--primary-color-pale: hsl(var(--primary-color-hue), 50%, 55%);
42+
--primary-color-dark: hsl(var(--primary-color-hue), 50%, 55%);
43+
--primary-color-hover: hsl(0, 0%, 30%);
44+
--action-color: hsl(var(--primary-color-hue), 50%, 60%);
45+
--action-color-inactive: hsl(var(--primary-color-hue), 50%, 30%);
46+
--link-item-background: hsl(0, 0%, 40%);
47+
--link-item-background-hover: hsl(0, 0%, 35%);
48+
--link-item-color: hsl(var(--primary-color-hue), 0%, 90%);
49+
--link-item-color-hover: hsl(var(--primary-color-hue), 0%, 100%);
50+
--link-item-focus-border: hsl(0, 0%, 50%);
51+
--input-background: hsl(0, 0%, 40%);
52+
--input-text-color: hsl(0, 0%, 95%);
53+
--input-placeholder-color: hsl(0, 0%, 70%);
54+
--logo-char-1-color: hsl(var(--primary-color-hue), 60%, 55%);
55+
--logo-char-2-color: hsl(var(--primary-color-hue), 60%, 80%);
56+
--logo-shape-color: hsl(var(--primary-color-hue), 60%, 55%);
57+
--footer-text-color: hsl(0, 0%, 80%);
58+
--footer-link-color: var(--primary-color-pale);
59+
--footer-link-color-hover: hsl(var(--primary-color-hue), 50%, 80%);
60+
--footer-link-background-hover: hsl(0, 0%, 35%);
61+
--footer-background: hsl(0, 0%, 30%);
62+
--footer-border: hsl(0, 0%, 40%);
63+
}
64+
65+
body {
66+
@include lightTheme();
67+
@media (prefers-color-scheme: dark) { @include darkTheme(); }
68+
&.light-theme { @include lightTheme(); }
69+
&.dark-theme { @include darkTheme(); }
70+
}

src/services/localStorageService.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
type StorageKey = 'wdh:hidden-items'
1+
import { themeStates, ThemeState } from '../App'
2+
3+
type StorageKey = 'wdh:hidden-items' | 'sdh:theme-setting'
24

35
export function getHiddenLinks (): string[] {
46
const key: StorageKey = 'wdh:hidden-items'
@@ -36,3 +38,23 @@ export function setHiddenLinks (values: string[]): void {
3638
throw new Error('[setHiddenLinks()] Values cannot be serialized to JSON.')
3739
}
3840
}
41+
42+
export function getThemeStateSetting (): ThemeState {
43+
const key: StorageKey = 'sdh:theme-setting'
44+
const storageString = localStorage.getItem(key)
45+
46+
if (storageString === null) { return 'auto' }
47+
if (
48+
storageString !== 'auto' &&
49+
storageString !== 'light' &&
50+
storageString !== 'dark'
51+
) { return 'auto' }
52+
53+
return storageString
54+
}
55+
56+
export function setThemeStateSetting (value: ThemeState): void {
57+
const key: StorageKey = 'sdh:theme-setting'
58+
59+
localStorage.setItem(key, value)
60+
}

0 commit comments

Comments
 (0)