From 4ba38aad95422f45c8930eef8ded3c046a9ffb7a Mon Sep 17 00:00:00 2001 From: David Dal Busco Date: Wed, 12 Feb 2025 14:15:35 +0100 Subject: [PATCH] feat: option to reset theme to system settings (#585) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Motivation In OISY, we would like to offer users the ability to select "System" in addition to Dark or Light. Technically, this means reverting the theme to its original value—removing it from local storage and deriving the theme store value from the system settings. # Changes - Add a function to get the theme from the system settings (`matchMedia`). - Add a function to `applyTheme` but, delete the item in the `localStorage`. - Add function `resetToSystemSettings` to store. - A bit unrelated (but not that much): we use the initial-value function directly in the initialization of the store, instead of creating it outside. I think it makes more sense since we do not want a static value when creating this store, but we want to capture the current theme. # Tests Added a few tests for `themeStore` and `initThemeStore`. --------- Co-authored-by: Antonio Ventilii Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Yusef Habib --- src/lib/stores/theme.store.ts | 18 ++- src/lib/utils/theme.utils.ts | 14 ++ src/tests/lib/stores/theme.store.spec.ts | 178 +++++++++++++++++++++++ 3 files changed, 207 insertions(+), 3 deletions(-) create mode 100644 src/tests/lib/stores/theme.store.spec.ts diff --git a/src/lib/stores/theme.store.ts b/src/lib/stores/theme.store.ts index 75d60824..90ccc62a 100644 --- a/src/lib/stores/theme.store.ts +++ b/src/lib/stores/theme.store.ts @@ -1,16 +1,22 @@ import type { Theme } from "$lib/types/theme"; -import { applyTheme, initTheme } from "$lib/utils/theme.utils"; +import { + applyTheme, + getThemeFromSystemSettings, + initTheme, + resetTheme, +} from "$lib/utils/theme.utils"; import { writable, type Readable } from "svelte/store"; export type ThemeStoreData = Theme | undefined; export interface ThemeStore extends Readable { select: (theme: Theme) => void; + resetToSystemSettings: () => void; } -const initialTheme: ThemeStoreData = initTheme(); - export const initThemeStore = (): ThemeStore => { + const initialTheme: ThemeStoreData = initTheme(); + const { subscribe, set } = writable(initialTheme); return { @@ -20,6 +26,12 @@ export const initThemeStore = (): ThemeStore => { applyTheme({ theme, preserve: true }); set(theme); }, + + resetToSystemSettings: () => { + const theme = getThemeFromSystemSettings(); + resetTheme(theme); + set(theme); + }, }; }; diff --git a/src/lib/utils/theme.utils.ts b/src/lib/utils/theme.utils.ts index b8921dc2..be18fdc7 100644 --- a/src/lib/utils/theme.utils.ts +++ b/src/lib/utils/theme.utils.ts @@ -50,3 +50,17 @@ export const applyTheme = ({ localStorage.setItem(LOCALSTORAGE_THEME_KEY, JSON.stringify(theme)); } }; + +export const getThemeFromSystemSettings = (): Theme => { + const isDarkPreferred = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + + return isDarkPreferred ? Theme.DARK : Theme.LIGHT; +}; + +export const resetTheme = (theme: Theme) => { + localStorage.removeItem(LOCALSTORAGE_THEME_KEY); + + applyTheme({ theme, preserve: false }); +}; diff --git a/src/tests/lib/stores/theme.store.spec.ts b/src/tests/lib/stores/theme.store.spec.ts new file mode 100644 index 00000000..89f9fd59 --- /dev/null +++ b/src/tests/lib/stores/theme.store.spec.ts @@ -0,0 +1,178 @@ +import { initThemeStore, themeStore } from "$lib"; +import { Theme } from "$lib/types/theme"; +import * as envUtils from "$lib/utils/env.utils"; +import * as themeUtils from "$lib/utils/theme.utils"; +import { + LOCALSTORAGE_THEME_KEY, + THEME_ATTRIBUTE, +} from "$lib/utils/theme.utils"; +import { get } from "svelte/store"; +import type { MockInstance } from "vitest"; + +describe("theme-store", () => { + let initThemeSpy: MockInstance; + let applyThemeSpy: MockInstance; + let resetThemeSpy: MockInstance; + + beforeEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.resetAllMocks(); + + window.document.documentElement.removeAttribute(THEME_ATTRIBUTE); + + vi.spyOn(envUtils, "isNode").mockReturnValue(false); + + initThemeSpy = vi.spyOn(themeUtils, "initTheme"); + applyThemeSpy = vi.spyOn(themeUtils, "applyTheme"); + resetThemeSpy = vi.spyOn(themeUtils, "resetTheme"); + }); + + afterAll(() => { + window.document.documentElement.removeAttribute(THEME_ATTRIBUTE); + }); + + it("should initialize with no theme if the theme is not set", () => { + expect(get(themeStore)).toBeUndefined(); + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBeNull(); + expect(localStorage.getItem(LOCALSTORAGE_THEME_KEY)).toBeNull(); + }); + + it.each(Object.values(Theme))( + "should initialise with the current '%s' theme", + (theme) => { + window.document.documentElement.setAttribute(THEME_ATTRIBUTE, theme); + + const themeStore = initThemeStore(); + + expect(get(themeStore)).toBe(theme); + expect(initThemeSpy).toHaveBeenCalledOnce(); + }, + ); + + it("should apply and store the selected theme", () => { + expect(get(themeStore)).toBeUndefined(); + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBeNull(); + expect(localStorage.getItem(LOCALSTORAGE_THEME_KEY)).toBeNull(); + + themeStore.select(Theme.LIGHT); + + expect(get(themeStore)).toBe(Theme.LIGHT); + expect(applyThemeSpy).toHaveBeenCalledOnce(); + expect(applyThemeSpy).toHaveBeenCalledWith({ + theme: Theme.LIGHT, + preserve: true, + }); + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe( + Theme.LIGHT, + ); + expect(localStorage.getItem(LOCALSTORAGE_THEME_KEY)).toBe( + JSON.stringify(Theme.LIGHT), + ); + + themeStore.select(Theme.DARK); + + expect(get(themeStore)).toBe(Theme.DARK); + expect(applyThemeSpy).toHaveBeenCalledTimes(2); + expect(applyThemeSpy).toHaveBeenCalledWith({ + theme: Theme.DARK, + preserve: true, + }); + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe( + Theme.DARK, + ); + expect(localStorage.getItem(LOCALSTORAGE_THEME_KEY)).toBe( + JSON.stringify(Theme.DARK), + ); + + // Just to double-check, we set it to light once more + themeStore.select(Theme.LIGHT); + + expect(get(themeStore)).toBe(Theme.LIGHT); + expect(applyThemeSpy).toHaveBeenCalledTimes(3); + expect(applyThemeSpy).toHaveBeenCalledWith({ + theme: Theme.LIGHT, + preserve: true, + }); + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe( + Theme.LIGHT, + ); + expect(localStorage.getItem(LOCALSTORAGE_THEME_KEY)).toBe( + JSON.stringify(Theme.LIGHT), + ); + }); + + it("should reset to the current system theme", () => { + const originalMatchMedia = window.matchMedia; + + // We mock window.matchMedia to match the DARK theme + Object.defineProperty(window, "matchMedia", { + value: vi.fn().mockImplementation(() => ({ + matches: true, + })), + }); + + // We first set the store, then we mock that the attribute may be changed in a different way + themeStore.select(Theme.LIGHT); + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe( + Theme.LIGHT, + ); + window.document.documentElement.setAttribute(THEME_ATTRIBUTE, Theme.DARK); + + themeStore.resetToSystemSettings(); + + expect(get(themeStore)).toBe(Theme.DARK); + expect(resetThemeSpy).toHaveBeenCalledWith(Theme.DARK); + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe( + Theme.DARK, + ); + expect(localStorage.getItem(LOCALSTORAGE_THEME_KEY)).toBeNull(); + + // We mock window.matchMedia to match the LIGHT theme + Object.defineProperty(window, "matchMedia", { + value: vi.fn().mockImplementation(() => ({ + matches: false, + })), + }); + + // We first set the store, then we mock that the attribute may be changed in a different way + themeStore.select(Theme.DARK); + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe( + Theme.DARK, + ); + window.document.documentElement.setAttribute(THEME_ATTRIBUTE, Theme.LIGHT); + + themeStore.resetToSystemSettings(); + + expect(get(themeStore)).toBe(Theme.LIGHT); + expect(resetThemeSpy).toHaveBeenCalledWith(Theme.LIGHT); + expect(document.documentElement.getAttribute(THEME_ATTRIBUTE)).toBe( + Theme.LIGHT, + ); + expect(localStorage.getItem(LOCALSTORAGE_THEME_KEY)).toBeNull(); + + Object.defineProperty(window, "matchMedia", { + writable: true, + value: originalMatchMedia, + }); + }); + + it("should handle gracefully when the theme is not set", () => { + window.document.documentElement.removeAttribute(THEME_ATTRIBUTE); + + const themeStore = initThemeStore(); + + expect(get(themeStore)).toBe(Theme.DARK); + }); + + it("should handle gracefully when the theme is not correctly set", () => { + window.document.documentElement.setAttribute( + THEME_ATTRIBUTE, + "invalid-theme", + ); + + const themeStore = initThemeStore(); + + expect(get(themeStore)).toBe(Theme.DARK); + }); +});