vueless
Version:
Vue Styleless UI Component Library, powered by Tailwind CSS.
743 lines (621 loc) • 25.2 kB
text/typescript
import { cloneDeep, merge } from "lodash-es";
import { vuelessConfig } from "./ui";
import { isCSR, getStored, getCookie, setCookie, deleteCookie, toNumber } from "./helper";
import {
PX_IN_REM,
AUTO_MODE_KEY,
COLOR_MODE_KEY,
LIGHT_MODE_CLASS,
DARK_MODE_CLASS,
GRAYSCALE_COLOR,
DEFAULT_PRIMARY_COLOR,
DEFAULT_NEUTRAL_COLOR,
OUTLINE,
DEFAULT_OUTLINE,
OUTLINE_DECREMENT,
OUTLINE_INCREMENT,
ROUNDING,
DEFAULT_ROUNDING,
ROUNDING_DECREMENT,
ROUNDING_INCREMENT,
NEUTRAL_COLOR,
PRIMARY_COLOR,
COLOR_SHADES,
DEFAULT_LIGHT_THEME,
DEFAULT_DARK_THEME,
PRIMARY_COLORS,
NEUTRAL_COLORS,
TEXT,
DEFAULT_TEXT,
TEXT_INCREMENT,
TEXT_DECREMENT,
DISABLED_OPACITY,
DEFAULT_DISABLED_OPACITY,
LETTER_SPACING,
DEFAULT_LETTER_SPACING,
} from "../constants";
import type {
NeutralColors,
PrimaryColors,
ThemeConfig,
ThemeConfigText,
ThemeConfigOutline,
ThemeConfigRounding,
MergedThemeConfig,
VuelessCssVariables,
} from "../types";
import { ColorMode } from "../types";
declare interface RootCSSVariableOptions {
primary: PrimaryColors | string;
neutral: NeutralColors | string;
text: ThemeConfigText;
rounding: ThemeConfigRounding;
outline: ThemeConfigOutline;
letterSpacing: number;
disabledOpacity: number;
lightTheme: Partial<VuelessCssVariables>;
darkTheme: Partial<VuelessCssVariables>;
}
declare interface SetColorMode {
colorMode: `${ColorMode}`;
isColorModeAuto: boolean;
}
/* Creates a media query that checks if the user's system color scheme is set to the dark. */
const prefersColorSchemeDark = isCSR && window.matchMedia("(prefers-color-scheme: dark)");
function toggleColorModeClass() {
if (!prefersColorSchemeDark) return;
const colorMode = prefersColorSchemeDark.matches ? ColorMode.Dark : ColorMode.Light;
setCookie(COLOR_MODE_KEY, colorMode);
setCookie(AUTO_MODE_KEY, String(Number(true)));
document.documentElement.classList.toggle(DARK_MODE_CLASS, prefersColorSchemeDark.matches);
document.documentElement.classList.toggle(LIGHT_MODE_CLASS, !prefersColorSchemeDark.matches);
}
/**
* Sets the client-side rendering (CSR) color mode by applying the specified mode,
* configuring the appropriate event listeners, setting CSS classes, and saving the mode
* in cookies and local storage.
*
* @param {`${ColorMode}`} mode - The desired color mode (dark | light | auto).
* @return {Object} An object containing:
* - `colorMode` {string}: The applied color mode (e.g., "light", "dark").
* - `isColorModeAuto` {boolean}: Indicates whether the color mode is set to auto.
*/
function setCSRColorMode(mode: `${ColorMode}`): SetColorMode {
const colorMode = mode || getCookie(COLOR_MODE_KEY) || vuelessConfig.colorMode || ColorMode.Light;
const isCachedAutoMode = !!Number(getCookie(AUTO_MODE_KEY) ?? 0);
const isAutoMode = colorMode === ColorMode.Auto;
const isSystemDarkMode = isAutoMode && prefersColorSchemeDark && prefersColorSchemeDark?.matches;
const isDarkMode = colorMode === ColorMode.Dark || isSystemDarkMode;
/* Removing system color mode change event listener. */
if (prefersColorSchemeDark) {
prefersColorSchemeDark.removeEventListener("change", toggleColorModeClass);
}
/* Adding system color mode change event listener. */
if ((isAutoMode || isCachedAutoMode) && prefersColorSchemeDark) {
prefersColorSchemeDark.addEventListener("change", toggleColorModeClass);
}
/* Adding color mode classes. */
document.documentElement.classList.toggle(DARK_MODE_CLASS, isDarkMode);
document.documentElement.classList.toggle(LIGHT_MODE_CLASS, !isDarkMode);
/* Dispatching custom event for the useDarkMode composable. */
window.dispatchEvent(new CustomEvent("darkModeChange", { detail: isDarkMode }));
/* Saving color mode value into cookies (server) and local storage (client). */
let currentColorMode = colorMode;
if (isAutoMode) {
currentColorMode = isDarkMode ? ColorMode.Dark : ColorMode.Light;
}
/* Define color mode cookies to be used in both CSR and SSR */
if (mode || getCookie(AUTO_MODE_KEY) === undefined) {
setCookie(COLOR_MODE_KEY, currentColorMode);
setCookie(AUTO_MODE_KEY, String(Number(isAutoMode)));
if (mode !== ColorMode.Auto && prefersColorSchemeDark) {
prefersColorSchemeDark.removeEventListener("change", toggleColorModeClass);
}
}
return {
colorMode: currentColorMode,
isColorModeAuto: isAutoMode || isCachedAutoMode,
};
}
/**
* Gets server-side rendering (SSR) color mode.
*
* @param {`${ColorMode}`} mode - The desired color mode (dark | light | auto).
* @param {boolean} isColorModeAuto - Indicates whether the color mode is set to auto.
* @return {Object} An object containing:
* - `colorMode` {string}: The applied color mode (e.g., "light", "dark").
* - `isColorModeAuto` {boolean}: Indicates whether the color mode is set to auto.
*/
function getSSRColorMode(mode: `${ColorMode}`, isColorModeAuto: boolean = false): SetColorMode {
const currentColorMode = mode || vuelessConfig.colorMode || ColorMode.Light;
const isAutoMode = currentColorMode === ColorMode.Auto;
return {
colorMode: currentColorMode,
isColorModeAuto: isAutoMode || isColorModeAuto,
};
}
/**
* Retrieves CSS variable value to be easily used in the JavaScript code.
* @return string.
*/
export function cssVar(name: string) {
return (isCSR && getComputedStyle(document.documentElement).getPropertyValue(name)) || undefined;
}
/**
* Resets all theme data by clearing cookies and localStorage.
* This removes all stored theme preferences including color mode, colors, text sizes,
* outline sizes, rounding values, letter spacing, and disabled opacity.
*/
export function resetTheme() {
if (!isCSR) return;
const themeKeys = [
AUTO_MODE_KEY,
COLOR_MODE_KEY,
`vl-${PRIMARY_COLOR}`,
`vl-${NEUTRAL_COLOR}`,
`vl-${TEXT}-xs`,
`vl-${TEXT}-sm`,
`vl-${TEXT}-md`,
`vl-${TEXT}-lg`,
`vl-${OUTLINE}-sm`,
`vl-${OUTLINE}-md`,
`vl-${OUTLINE}-lg`,
`vl-${ROUNDING}-sm`,
`vl-${ROUNDING}-md`,
`vl-${ROUNDING}-lg`,
`vl-${LETTER_SPACING}`,
`vl-${DISABLED_OPACITY}`,
];
themeKeys.forEach((key) => {
localStorage.removeItem(key);
deleteCookie(key);
});
}
/**
* Retrieves the current theme configuration.
* @return ThemeConfig - current theme configuration
*/
export function getTheme(config?: ThemeConfig): MergedThemeConfig {
const { colorMode, isColorModeAuto } = isCSR
? setCSRColorMode(config?.colorMode as ColorMode)
: getSSRColorMode(config?.colorMode as ColorMode, config?.isColorModeAuto);
const primary = getPrimaryColor(config?.primary);
const neutral = getNeutralColor(config?.neutral);
const text = getText(config?.text);
const outline = getOutlines(config?.outline);
const rounding = getRoundings(config?.rounding);
const letterSpacing = getLetterSpacing(config?.letterSpacing);
const disabledOpacity = getDisabledOpacity(config?.disabledOpacity);
const lightTheme = merge({}, DEFAULT_LIGHT_THEME, vuelessConfig.lightTheme);
const darkTheme = merge({}, DEFAULT_DARK_THEME, vuelessConfig.darkTheme);
return {
colorMode,
isColorModeAuto,
primary,
neutral,
text,
outline,
rounding,
letterSpacing,
disabledOpacity,
lightTheme,
darkTheme,
};
}
/**
* Applying theme settings.
* Changes and reset Vueless CSS variables.
* @return string - CSS variables
*/
export function setTheme(config: ThemeConfig = {}) {
isCSR
? setCSRColorMode(config.colorMode as ColorMode)
: getSSRColorMode(config.colorMode as ColorMode);
const text = getText(config.text);
const outline = getOutlines(config.outline);
const rounding = getRoundings(config.rounding);
const letterSpacing = getLetterSpacing(config.letterSpacing);
const disabledOpacity = getDisabledOpacity(config.disabledOpacity);
let primary = getPrimaryColor(config.primary);
const neutral = getNeutralColor(config.neutral);
const lightTheme = merge({}, DEFAULT_LIGHT_THEME, vuelessConfig.lightTheme, config.lightTheme);
const darkTheme = merge({}, DEFAULT_DARK_THEME, vuelessConfig.darkTheme, config.darkTheme);
/* Redeclare primary color if grayscale color set as default */
if (primary === GRAYSCALE_COLOR) {
primary = neutral;
["", "lifted", "accented"].forEach((shade) => {
const primaryShade: keyof VuelessCssVariables = shade
? `--vl-primary-${shade}`
: "--vl-primary";
const grayscaleShade: keyof VuelessCssVariables = shade
? `--vl-grayscale-${shade}`
: "--vl-grayscale";
/* Dark theme: if the primary color shade is not defined in global or runtime config, use the grayscale color. */
const hasGlobalDarkPrimaryColor = hasPrimaryColor(
vuelessConfig.darkTheme,
DEFAULT_DARK_THEME,
primaryShade,
);
const hasRuntimeDarkPrimaryColor = hasPrimaryColor(
config.darkTheme,
DEFAULT_DARK_THEME,
primaryShade,
);
if (!hasGlobalDarkPrimaryColor && !hasRuntimeDarkPrimaryColor) {
darkTheme[primaryShade] = darkTheme[grayscaleShade];
}
/* Light theme: if the primary color shade is not defined in global or runtime config, use the grayscale color. */
const hasGlobalLightPrimaryColor = hasPrimaryColor(
vuelessConfig.lightTheme,
DEFAULT_LIGHT_THEME,
primaryShade,
);
const hasRuntimeLightPrimaryColor = hasPrimaryColor(
config.lightTheme,
DEFAULT_LIGHT_THEME,
primaryShade,
);
if (!hasGlobalLightPrimaryColor && !hasRuntimeLightPrimaryColor) {
lightTheme[primaryShade] = lightTheme[grayscaleShade];
}
});
}
return setRootCSSVariables({
primary,
neutral,
text,
outline,
rounding,
letterSpacing,
disabledOpacity,
lightTheme,
darkTheme,
});
}
/**
* Normalizes the provided theme configuration object into a structured format.
*
* @param {object} theme - The theme configuration object to normalize.
* @return {MergedThemeConfig}
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function normalizeThemeConfig(theme: any): MergedThemeConfig {
return {
colorMode: theme.colorMode,
isColorModeAuto: theme.isColorModeAuto,
primary: theme.primary,
neutral: theme.neutral,
text: {
xs: toNumber(theme.text?.xs),
sm: toNumber(theme.text?.sm),
md: toNumber(theme.text?.md),
lg: toNumber(theme.text?.lg),
},
outline: {
sm: toNumber(theme.outline?.sm),
md: toNumber(theme.outline?.md),
lg: toNumber(theme.outline?.lg),
},
rounding: {
sm: toNumber(theme.rounding?.sm),
md: toNumber(theme.rounding?.md),
lg: toNumber(theme.rounding?.lg),
},
letterSpacing: toNumber(theme.letterSpacing),
disabledOpacity: toNumber(theme.disabledOpacity),
};
}
/**
* Determines if the provided color mode configuration has a primary color
* that differs from the default color mode configuration.
* @return {boolean}
*/
function hasPrimaryColor(
colorModeConfig: Partial<VuelessCssVariables> | undefined,
defaultColorModeConfig: Partial<VuelessCssVariables>,
primaryShade: keyof VuelessCssVariables,
) {
const shade = colorModeConfig?.[primaryShade];
const defaultShade = defaultColorModeConfig?.[primaryShade];
return shade && shade !== defaultShade;
}
/**
* Retrieve primary color value and save them to cookie and localStorage.
* @return string - primary color.
*/
function getPrimaryColor(primary?: PrimaryColors) {
const storageKey = `vl-${PRIMARY_COLOR}`;
let primaryColor: PrimaryColors =
primary ?? getStored(storageKey) ?? vuelessConfig.primary ?? DEFAULT_PRIMARY_COLOR;
const isPrimaryColor =
PRIMARY_COLORS.some((color) => color === primaryColor) || primaryColor === GRAYSCALE_COLOR;
if (!isPrimaryColor) {
// eslint-disable-next-line no-console
console.warn(`The primary color '${primaryColor}' is missing in your palette.`);
primaryColor = DEFAULT_PRIMARY_COLOR;
}
if (isCSR && primary) {
setCookie(storageKey, String(primaryColor));
localStorage.setItem(storageKey, String(primaryColor));
}
return primaryColor;
}
/**
* Retrieve neutral color value and save them to cookie and localStorage.
* @return string - neutral color.
*/
function getNeutralColor(neutral?: NeutralColors) {
const storageKey = `vl-${NEUTRAL_COLOR}`;
let neutralColor: NeutralColors =
neutral ?? getStored(storageKey) ?? vuelessConfig.neutral ?? DEFAULT_NEUTRAL_COLOR;
const isNeutralColor = NEUTRAL_COLORS.some((color) => color === neutralColor);
if (!isNeutralColor) {
// eslint-disable-next-line no-console
console.warn(`The neutral color '${neutralColor}' is missing in your palette.`);
neutralColor = DEFAULT_NEUTRAL_COLOR;
}
if (isCSR && neutral) {
setCookie(storageKey, String(neutralColor));
localStorage.setItem(storageKey, String(neutralColor));
}
return neutralColor;
}
/**
* Calculate font-size values and save them to cookie and localStorage.
* @return object - xs, sm, md, lg font-size values.
*/
function getText(text?: ThemeConfig["text"]) {
const storageKey = {
xs: `vl-${TEXT}-xs`,
sm: `vl-${TEXT}-sm`,
md: `vl-${TEXT}-md`,
lg: `vl-${TEXT}-lg`,
};
const runtimeText = primitiveToObject(text) as ThemeConfigText;
const globalText = primitiveToObject(vuelessConfig.text) as ThemeConfigText;
const storedText = {
xs: getStored(storageKey.xs),
sm: getStored(storageKey.sm),
md: getStored(storageKey.md),
lg: getStored(storageKey.lg),
};
const textMd = Math.max(0, Number(runtimeText.md ?? globalText.md ?? DEFAULT_TEXT));
const textXs = Math.max(0, textMd - TEXT_DECREMENT * 2);
const textSm = Math.max(0, textMd - TEXT_DECREMENT);
const textLg = Math.max(0, textMd + TEXT_INCREMENT);
const definedText = {
xs: Math.max(0, Number(runtimeText.xs ?? getStored(storageKey.xs) ?? globalText.xs ?? 0)),
sm: Math.max(0, Number(runtimeText.sm ?? getStored(storageKey.sm) ?? globalText.sm ?? 0)),
md: Math.max(0, Number(runtimeText.md ?? getStored(storageKey.md) ?? globalText.md ?? 0)),
lg: Math.max(0, Number(runtimeText.lg ?? getStored(storageKey.lg) ?? globalText.lg ?? 0)),
};
/* eslint-disable prettier/prettier,vue/max-len */
const mergedText = {
xs: runtimeText.xs === undefined && globalText.xs === undefined && (storedText.xs === undefined || typeof text === "number") ? textXs : definedText.xs,
sm: runtimeText.sm === undefined && globalText.sm === undefined && (storedText.sm === undefined || typeof text === "number") ? textSm : definedText.sm,
md: runtimeText.md === undefined && globalText.md === undefined && storedText.md === undefined ? textMd : definedText.md,
lg: runtimeText.lg === undefined && globalText.lg === undefined && (storedText.lg === undefined || typeof text === "number") ? textLg : definedText.lg,
};
/* eslint-enable prettier/prettier,vue/max-len */
if (isCSR && text !== undefined) {
setCookie(storageKey.xs, String(mergedText.xs));
setCookie(storageKey.sm, String(mergedText.sm));
setCookie(storageKey.md, String(mergedText.md));
setCookie(storageKey.lg, String(mergedText.lg));
localStorage.setItem(storageKey.xs, String(mergedText.xs));
localStorage.setItem(storageKey.sm, String(mergedText.sm));
localStorage.setItem(storageKey.md, String(mergedText.md));
localStorage.setItem(storageKey.lg, String(mergedText.lg));
}
return mergedText;
}
/**
* Calculate outline values and save them to cookie and localStorage.
* @return object - sm, md, lg outline values.
*/
function getOutlines(outline?: ThemeConfig["outline"]) {
const storageKey = {
sm: `vl-${OUTLINE}-sm`,
md: `vl-${OUTLINE}-md`,
lg: `vl-${OUTLINE}-lg`,
};
const runtimeOutline = primitiveToObject(outline) as ThemeConfigOutline;
const globalOutline = primitiveToObject(vuelessConfig.outline) as ThemeConfigOutline;
const storedOutline = {
sm: getStored(storageKey.sm),
md: getStored(storageKey.md),
lg: getStored(storageKey.lg),
};
const outlineMd = Math.max(0, Number(runtimeOutline.md ?? globalOutline.md ?? DEFAULT_OUTLINE));
const outlineSm = Math.max(0, outlineMd - OUTLINE_DECREMENT);
let outlineLg = Math.max(0, outlineMd + OUTLINE_INCREMENT);
if (outlineMd === 0) {
outlineLg = 0;
}
const definedOutline = {
sm: Math.max(0, Number(runtimeOutline.sm ?? getStored(storageKey.sm) ?? globalOutline.sm ?? 0)),
md: Math.max(0, Number(runtimeOutline.md ?? getStored(storageKey.md) ?? globalOutline.md ?? 0)),
lg: Math.max(0, Number(runtimeOutline.lg ?? getStored(storageKey.lg) ?? globalOutline.lg ?? 0)),
};
/* eslint-disable prettier/prettier,vue/max-len */
const mergedOutline = {
sm: runtimeOutline.sm === undefined && globalOutline.sm === undefined && (storedOutline.sm === undefined || typeof outline === "number") ? outlineSm : definedOutline.sm,
md: runtimeOutline.md === undefined && globalOutline.md === undefined && storedOutline.md === undefined ? outlineMd : definedOutline.md,
lg: runtimeOutline.lg === undefined && globalOutline.lg === undefined && (storedOutline.lg === undefined || typeof outline === "number") ? outlineLg : definedOutline.lg,
};
/* eslint-enable prettier/prettier,vue/max-len */
if (isCSR && outline !== undefined) {
setCookie(storageKey.sm, String(mergedOutline.sm));
setCookie(storageKey.md, String(mergedOutline.md));
setCookie(storageKey.lg, String(mergedOutline.lg));
localStorage.setItem(storageKey.sm, String(mergedOutline.sm));
localStorage.setItem(storageKey.md, String(mergedOutline.md));
localStorage.setItem(storageKey.lg, String(mergedOutline.lg));
}
return mergedOutline;
}
/**
* Calculate rounding values and save them to cookie and localStorage.
* @return object - sm, md, lg rounding values.
*/
function getRoundings(rounding?: ThemeConfig["rounding"]) {
const storageKey = {
sm: `vl-${ROUNDING}-sm`,
md: `vl-${ROUNDING}-md`,
lg: `vl-${ROUNDING}-lg`,
};
const runtimeRounding = primitiveToObject(rounding) as ThemeConfigRounding;
const globalRounding = primitiveToObject(vuelessConfig.rounding) as ThemeConfigRounding;
const storedRounding = {
sm: getStored(storageKey.sm),
md: getStored(storageKey.md),
lg: getStored(storageKey.lg),
};
// eslint-disable-next-line prettier/prettier
const roundingMd = Math.max(0, Number(runtimeRounding.md ?? globalRounding.md ?? DEFAULT_ROUNDING));
let roundingSm = Math.max(0, roundingMd - ROUNDING_DECREMENT);
let roundingLg = Math.max(0, roundingMd + ROUNDING_INCREMENT);
if (roundingMd === ROUNDING_INCREMENT) {
roundingSm = ROUNDING_DECREMENT;
}
if (roundingMd === ROUNDING_DECREMENT) {
roundingSm = ROUNDING_INCREMENT - ROUNDING_DECREMENT;
}
if (roundingMd === 0) {
roundingLg = ROUNDING_INCREMENT - ROUNDING_DECREMENT;
}
/* eslint-disable prettier/prettier,vue/max-len */
const definedRounding = {
sm: Math.max(0, Number(runtimeRounding.sm ?? getStored(storageKey.sm) ?? globalRounding.sm ?? 0)),
md: Math.max(0, Number(runtimeRounding.md ?? getStored(storageKey.md) ?? globalRounding.md ?? 0)),
lg: Math.max(0, Number(runtimeRounding.lg ?? getStored(storageKey.lg) ?? globalRounding.lg ?? 0)),
};
const mergedRounding = {
sm: runtimeRounding.sm === undefined && globalRounding.sm === undefined && (storedRounding.sm === undefined || typeof rounding === "number") ? roundingSm : definedRounding.sm,
md: runtimeRounding.md === undefined && globalRounding.md === undefined && storedRounding.md === undefined ? roundingMd : definedRounding.md,
lg: runtimeRounding.lg === undefined && globalRounding.lg === undefined && (storedRounding.lg === undefined || typeof rounding === "number") ? roundingLg : definedRounding.lg,
};
/* eslint-enable prettier/prettier,vue/max-len */
if (isCSR && rounding !== undefined) {
setCookie(storageKey.sm, String(mergedRounding.sm));
setCookie(storageKey.md, String(mergedRounding.md));
setCookie(storageKey.lg, String(mergedRounding.lg));
localStorage.setItem(storageKey.sm, String(mergedRounding.sm));
localStorage.setItem(storageKey.md, String(mergedRounding.md));
localStorage.setItem(storageKey.lg, String(mergedRounding.lg));
}
return mergedRounding;
}
/**
* Retrieve letter spacing value and save them to cookie and localStorage.
* @return number - letter spacing value.
*/
function getLetterSpacing(letterSpacing?: ThemeConfig["letterSpacing"]) {
const storageKey = `vl-${LETTER_SPACING}`;
const spacing = letterSpacing ?? getStored(storageKey) ?? vuelessConfig.letterSpacing;
const mergedSpacing = Math.max(0, Number(spacing ?? DEFAULT_LETTER_SPACING));
if (isCSR && letterSpacing !== undefined) {
setCookie(storageKey, String(mergedSpacing));
localStorage.setItem(storageKey, String(mergedSpacing));
}
return mergedSpacing;
}
/**
* Retrieve disabled opacity value and save them to cookie and localStorage.
* @return number - opacity value.
*/
function getDisabledOpacity(disabledOpacity?: ThemeConfig["disabledOpacity"]) {
const storageKey = `vl-${DISABLED_OPACITY}`;
const opacity = disabledOpacity ?? getStored(storageKey) ?? vuelessConfig.disabledOpacity;
const mergedOpacity = Math.max(0, Number(opacity ?? DEFAULT_DISABLED_OPACITY));
if (isCSR && disabledOpacity !== undefined) {
setCookie(storageKey, String(mergedOpacity));
localStorage.setItem(storageKey, String(mergedOpacity));
}
return mergedOpacity;
}
/**
* Generate and apply Vueless CSS variables.
* @return string - Vueless CSS variables string.
*/
function setRootCSSVariables(vars: RootCSSVariableOptions) {
let darkVariables: Partial<VuelessCssVariables> = {};
let variables: Partial<VuelessCssVariables> = {
"--vl-text-xs": `${vars.text.xs / PX_IN_REM}rem`,
"--vl-text-sm": `${vars.text.sm / PX_IN_REM}rem`,
"--vl-text-md": `${vars.text.md / PX_IN_REM}rem`,
"--vl-text-lg": `${vars.text.lg / PX_IN_REM}rem`,
"--vl-outline-sm": `${vars.outline.sm}px`,
"--vl-outline-md": `${vars.outline.md}px`,
"--vl-outline-lg": `${vars.outline.lg}px`,
"--vl-rounding-sm": `${vars.rounding.sm / PX_IN_REM}rem`,
"--vl-rounding-md": `${vars.rounding.md / PX_IN_REM}rem`,
"--vl-rounding-lg": `${vars.rounding.lg / PX_IN_REM}rem`,
"--vl-letter-spacing": `${vars.letterSpacing}em`,
"--vl-disabled-opacity": `${vars.disabledOpacity}%`,
};
for (const shade of COLOR_SHADES) {
variables[`--vl-${PRIMARY_COLOR}-${shade}` as keyof VuelessCssVariables] =
`var(--color-${vars.primary}-${shade})`;
}
for (const shade of COLOR_SHADES) {
variables[`--vl-${NEUTRAL_COLOR}-${shade}` as keyof VuelessCssVariables] =
`var(--color-${vars.neutral}-${shade})`;
}
const [light, dark] = generateCSSColorVariables(vars.lightTheme, vars.darkTheme);
variables = { ...variables, ...light };
darkVariables = { ...darkVariables, ...dark };
return setCSSVariables(variables, darkVariables);
}
/**
* Generate CSS color variables.
* @return string - Vueless color CSS variables.
*/
function generateCSSColorVariables(
lightTheme: Partial<VuelessCssVariables>,
darkTheme: Partial<VuelessCssVariables>,
) {
const variables: Partial<VuelessCssVariables> = {};
const darkVariables: Partial<VuelessCssVariables> = {};
Object.entries(lightTheme).forEach(([vuelessVariable, lightColor]) => {
const variable = vuelessVariable as keyof VuelessCssVariables;
variables[variable] = lightColor?.startsWith("--") ? `var(${lightColor})` : lightColor;
});
Object.entries(darkTheme).forEach(([vuelessVariable, darkColor]) => {
const variable = vuelessVariable as keyof VuelessCssVariables;
darkVariables[variable] = darkColor?.startsWith("--") ? `var(${darkColor})` : darkColor;
});
return [variables, darkVariables];
}
/**
* Converts CSS variables object into strings and apply them.
* @return string - Vueless CSS variables.
*/
function setCSSVariables(
variables: Partial<VuelessCssVariables>,
darkVariables: Partial<VuelessCssVariables>,
) {
const variablesString = Object.entries(variables)
.map(([key, value]) => `${key}: ${value};`)
.join(" ");
const darkVariablesString = Object.entries(darkVariables)
.map(([key, value]) => `${key}: ${value};`)
.join(" ");
const rootVariables = `
:root {${variablesString}}
.${DARK_MODE_CLASS} {${darkVariablesString}}
`;
if (isCSR) {
const style = document.createElement("style");
style.innerHTML = rootVariables;
document.head.appendChild(style);
}
return rootVariables;
}
/**
* Converts a primitive value into an object with the primitive value assigned to a key "md".
* If the provided value is already an object, it returns a deeply cloned copy of that object.
*/
function primitiveToObject(value: unknown): object {
return typeof value === "object" ? cloneDeep(value as object) : { md: value };
}