Skip to content

Commit

Permalink
feat: option to reset theme to system settings (#585)
Browse files Browse the repository at this point in the history
# 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 <[email protected]>
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Yusef Habib <[email protected]>
  • Loading branch information
4 people authored Feb 12, 2025
1 parent 00338d5 commit 4ba38aa
Show file tree
Hide file tree
Showing 3 changed files with 207 additions and 3 deletions.
18 changes: 15 additions & 3 deletions src/lib/stores/theme.store.ts
Original file line number Diff line number Diff line change
@@ -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<ThemeStoreData> {
select: (theme: Theme) => void;
resetToSystemSettings: () => void;
}

const initialTheme: ThemeStoreData = initTheme();

export const initThemeStore = (): ThemeStore => {
const initialTheme: ThemeStoreData = initTheme();

const { subscribe, set } = writable<ThemeStoreData>(initialTheme);

return {
Expand All @@ -20,6 +26,12 @@ export const initThemeStore = (): ThemeStore => {
applyTheme({ theme, preserve: true });
set(theme);
},

resetToSystemSettings: () => {
const theme = getThemeFromSystemSettings();
resetTheme(theme);
set(theme);
},
};
};

Expand Down
14 changes: 14 additions & 0 deletions src/lib/utils/theme.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
};
178 changes: 178 additions & 0 deletions src/tests/lib/stores/theme.store.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});

0 comments on commit 4ba38aa

Please sign in to comment.