Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add new themeStore core module for managing theme state #1951

Merged
merged 17 commits into from
Jul 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 26 additions & 26 deletions docs/src/components/ThemeSwitcherMenu.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,54 +4,54 @@ import Icon from "./Icon.astro";

<ul class="menu menu_themes">
<li class="menu__item">
<button type="button" data-value="root" class="menu__action">
<button type="button" data-set-theme="root" class="menu__action">
<svg class="icon icon_style_fill" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M21 12C21 7.36745 17.5 3.55237 13 3.05493L13 20.9451C17.4999 20.4476 21 16.6326 21 12ZM12 1C18.0751 1 23 5.92487 23 12C23 18.0751 18.0751 23 12 23C5.92487 23 0.999999 18.0751 1 12C1 5.92487 5.92487 0.999999 12 1Z" fill="currentColor" />
</svg>
<span>OS Default</span>
</button>
</li>
<li class="menu__item">
<button type="button" data-value="light" class="menu__action">
<button type="button" data-set-theme="light" class="menu__action">
<Icon name="sun" />
<span>Light</span>
</button>
</li>
<li class="menu__item">
<button type="button" data-value="dark" class="menu__action">
<button type="button" data-set-theme="dark" class="menu__action">
<Icon name="moon" />
<span>Dark</span>
</button>
</li>
</ul>

<script>
import { store } from "../modules/useThemeStore";
import { themeStore } from "@vrembem/core";
import { popover } from "../modules/usePopover";

function changeTheme(value) {
store.change(value);
}

function setActive(value) {
btns.forEach((btn) => btn.classList.remove("is-active"));
btns.forEach((btn) => {
if (btn.getAttribute("data-value") === value) {
btn.classList.add("is-active");
}
});
}
const btns = document.querySelectorAll("[data-set-theme]");

const btns = document.querySelectorAll(".menu_themes .menu__action");

btns.forEach((btn) => {
btn.addEventListener(("click"), () => {
const value = btn.getAttribute("data-value");
changeTheme(value);
setActive(value);
popover.close("popover-theme-switcher");
});
themeStore({
onInit() {
btns.forEach((btn) => {
toggleState.call(this, btn);
btn.addEventListener("click", () => {
this.theme = btn.getAttribute("data-set-theme");
popover.close("popover-theme-switcher");
});
});
},
onChange() {
btns.forEach((btn) => {
toggleState.call(this, btn);
});
}
});

setActive(store.theme);
function toggleState(btn) {
btn.getAttribute("data-set-theme") === this.theme
? btn.classList.add("is-active")
: btn.classList.remove("is-active");
}

</script>
29 changes: 0 additions & 29 deletions docs/src/modules/useThemeStore.js

This file was deleted.

1 change: 1 addition & 0 deletions packages/core/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,6 @@ export * from "./src/js/getConfig";
export * from "./src/js/getPrefix";
export * from "./src/js/localStore";
export * from "./src/js/teleport";
export * from "./src/js/themeStore";
export * from "./src/js/transition";
export * from "./src/js/updateGlobalState";
27 changes: 20 additions & 7 deletions packages/core/src/js/cssVar.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,20 @@ import { getPrefix } from "./getPrefix";

/**
* Get the value of a CSS custom property (variable).
* @param {String} property - The CSS custom property to query for.
* @param {Node} [el=document.body] - The element to get computed styles from.
* @return {String || Error} Return the CSS value or an error if none is found.
* @param {String} property
* The CSS custom property to query for.
* @param {Object} options
* An options object with optional configuration.
* @return {String || Error}
* Return the CSS value, a provided fallback or an error if none is found.
*/
export function cssVar(property, el = document.body) {
export function cssVar(property, options) {
const settings = {
fallback: null,
element: document.body,
...options
};

// If property doesn't have CSS variable double dash...
if (property.slice(0, 2) !== "--") {
// Get the prefix value.
Expand All @@ -22,15 +31,19 @@ export function cssVar(property, el = document.body) {
}

// Get the CSS value.
const cssValue = getComputedStyle(el).getPropertyValue(property).trim();
const cssValue = getComputedStyle(settings.element).getPropertyValue(property).trim();

// If a CSS value was found, return the CSS value.
if (cssValue) {
return cssValue;
}

// Else, return a blocking error.
// Else, return the fallback or a blocking error.
else {
throw new Error(`CSS variable "${property}" was not found!`);
if (settings.fallback) {
return settings.fallback;
} else {
throw new Error(`CSS variable "${property}" was not found!`);
}
}
}
93 changes: 93 additions & 0 deletions packages/core/src/js/themeStore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { localStore } from "@vrembem/core";
import { cssVar } from "@vrembem/core";

export function themeStore(options) {
// Setup the default settings object.
const settings = {
prefix: cssVar("prefix-themes", { fallback: "vb-theme-" }),
themes: ["root", "light", "dark"],
storeKey: "VB:Profile",
};

// Override all settings values with provided options.
for (const [key] of Object.entries(settings)) {
if (options && options[key]) {
settings[key] = options[key];
}
}

// Setup the default callbacks object.
const callbacks = {
onInit() {},
onChange() {},
};

// Override all callback values with provided options.
for (const [key] of Object.entries(callbacks)) {
if (options && options[key]) {
callbacks[key] = options[key];
}
}

// Get the local storage profile.
const profile = localStore(settings.storeKey);

// Setup the API object.
const api = {
// Store our settings in the API.
settings,

// Actions.
add(value) {
settings.themes.push(value);
},
remove(value) {
const index = settings.themes.indexOf(value);
(~index && settings.themes.splice(index, 1));
},
callback(name) {
callbacks[name].call(this);
},

// Getters.
get class() {
return `${settings.prefix}${this.theme}`;
},
get classes() {
return settings.themes.map((theme) => `${settings.prefix}${theme}`);
},
get themes() {
return settings.themes;
},

// Setup the theme get and set methods.
get theme() {
return profile.get("theme") || "root";
},
set theme(value) {
// Check if the value exists as a theme option.
if (settings.themes.includes(value)) {
// Check if the value is actually different from the one currently set.
if (this.theme != value) {
// Save the theme value to local storage.
profile.set("theme", value);
// Remove the theme classes from the html element.
document.documentElement.classList.remove(...this.classes);
// Add the new theme class to the html element.
document.documentElement.classList.add(`${settings.prefix}${value}`);
// Run the on change callback.
this.callback("onChange");
}
} else {
// Throw a console error if the theme doesn't exist as an option.
console.error(`Not a valid theme value: "${value}"`);
}
},
};

// Run the on initialization callback.
api.callback("onInit");

// Return the API.
return api;
}
2 changes: 1 addition & 1 deletion packages/core/src/js/transition.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export function transition(el, from, to, duration = "transition-duration") {
return new Promise((resolve) => {
// If duration is a string, query for the css var value.
if (typeof duration === "string") {
const cssValue = cssVar(duration, el);
const cssValue = cssVar(duration, { element: el });
// Convert value to ms if needed.
const ms = (cssValue.includes("ms")) ? true : false;
duration = parseFloat(cssValue) * ((ms) ? 1 : 1000);
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/scss/modules/_css.scss
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
/// @access private
$_variables: (
"core": (
"prefix": config.get("prefix-variables")
"prefix": config.get("prefix-variables"),
"prefix-themes": config.get("prefix-themes")
)
);

Expand Down
81 changes: 81 additions & 0 deletions packages/core/tests/themeStore.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { expect } from "vitest";
import { themeStore } from "../index";

let result;
let store;

test("should setup a theme store", () => {
store = themeStore();
expect(store.theme).toBe("root");
expect(store.class).toBe("vb-theme-root");
expect(store.themes).toStrictEqual(
["root", "light", "dark"]
);
expect(store.classes).toStrictEqual(
["vb-theme-root", "vb-theme-light", "vb-theme-dark"]
);
expect(localStorage.getItem("VB:Profile")).toBe(null);
});

test("should update html element class when theme is changed", () => {
result = document.documentElement.classList.contains("vb-theme-light");
expect(result).toBe(false);

store.theme = "light";

result = document.documentElement.classList.contains("vb-theme-light");
expect(result).toBe(true);
});

test("should have set the theme in local storage", () => {
result = JSON.parse(localStorage.getItem("VB:Profile"));
expect(result).toStrictEqual({"theme": "light"});
});

test("should be able to add and remove themes from the store", () => {
expect(store.themes).not.toContain("matrix");
expect(store.classes).not.toContain("vb-theme-matrix");
store.add("matrix");
expect(store.themes).toContain("matrix");
expect(store.classes).toContain("vb-theme-matrix");
store.remove("matrix");
expect(store.themes).not.toContain("matrix");
expect(store.classes).not.toContain("vb-theme-matrix");
});

test("should be able to set callbacks that get run on init and change", () => {
const onInitFunc = vi.fn();
const onChangeFunc = vi.fn();
expect(onInitFunc).not.toHaveBeenCalledOnce();
store = themeStore({
onInit: onInitFunc,
onChange: onChangeFunc
});
expect(onInitFunc).toHaveBeenCalledOnce();
expect(onChangeFunc).not.toHaveBeenCalled();
store.theme = "dark";
result = document.documentElement.classList.contains("vb-theme-dark");
expect(result).toBe(true);
expect(onChangeFunc).toHaveBeenCalled();
});

test("should update the settings object when options are passed", () => {
store = themeStore({
prefix: "sn-theme-",
storeKey: "SN:Key"
});
expect(store.settings.prefix).toBe("sn-theme-");
expect(store.class).toBe("sn-theme-root");
store.theme = "light";
expect(store.class).toBe("sn-theme-light");
result = JSON.parse(localStorage.getItem("SN:Key"));
expect(result).toStrictEqual({"theme": "light"});
});

test("should log a console error when trying to change to a theme that doesn't exist", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
store = themeStore();
store.theme = "asdf";
expect(consoleErrorSpy).toHaveBeenCalledWith("Not a valid theme value: \"asdf\"");
consoleErrorSpy.mockRestore();
});
Loading