UNPKG

vueless

Version:

Vue Styleless UI Component Library, powered by Tailwind CSS.

525 lines (425 loc) • 17.3 kB
import { cloneDeep, merge } from "lodash-es"; import { vuelessConfig } from "./ui.ts"; import { isCSR, setCookie } from "./helper.ts"; import { PX_IN_REM, COLOR_MODE_KEY, AUTO_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, } from "../constants.js"; import type { NeutralColors, PrimaryColors, ThemeConfig, ThemeConfigText, ThemeConfigOutline, ThemeConfigRounding, VuelessCssVariables, } from "../types.ts"; import { ColorMode } from "../types.ts"; declare interface RootCSSVariableOptions { primary: PrimaryColors | string; neutral: NeutralColors | string; text: ThemeConfigText; rounding: ThemeConfigRounding; outline: ThemeConfigOutline; disabledOpacity: number; lightTheme: Partial<VuelessCssVariables>; darkTheme: Partial<VuelessCssVariables>; } /* 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); localStorage.setItem(COLOR_MODE_KEY, colorMode); document.documentElement.classList.toggle(DARK_MODE_CLASS, prefersColorSchemeDark.matches); document.documentElement.classList.toggle(LIGHT_MODE_CLASS, !prefersColorSchemeDark.matches); } /** * Sets color mode. * @param {string} mode (dark | light | auto) * @param {boolean} isCachedAutoMode * @return {string} current color mode */ function setColorMode(mode: `${ColorMode}`, isCachedAutoMode?: boolean): string { const colorMode = mode || getStored(COLOR_MODE_KEY) || vuelessConfig.colorMode || ColorMode.Light; isCachedAutoMode = isCachedAutoMode ?? !!Number(getStored(AUTO_MODE_KEY)); 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; } if (mode) { setCookie(COLOR_MODE_KEY, currentColorMode); setCookie(AUTO_MODE_KEY, String(Number(isAutoMode || isCachedAutoMode))); localStorage.setItem(COLOR_MODE_KEY, currentColorMode); localStorage.setItem(AUTO_MODE_KEY, String(Number(isAutoMode || isCachedAutoMode))); } return currentColorMode; } /** * 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; } /** * Get a stored value from local storage. * @return string | undefined */ export function getStored(key: string) { return isCSR ? localStorage.getItem(key) : undefined; } /** * Applying theme settings. * Changes and reset Vueless CSS variables. * @return string - CSS variables */ export function setTheme(config: ThemeConfig = {}, isCachedAutoMode?: boolean) { if (isCSR) setColorMode(config.colorMode as ColorMode, isCachedAutoMode); const text = getText(config.text); const outline = getOutlines(config.outline); const rounding = getRoundings(config.rounding); 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"; if (!vuelessConfig.darkTheme?.[primaryShade] && !config.darkTheme?.[primaryShade]) { darkTheme[primaryShade] = darkTheme[grayscaleShade]; } if (!vuelessConfig.lightTheme?.[primaryShade] && !config.lightTheme?.[primaryShade]) { lightTheme[primaryShade] = lightTheme[grayscaleShade]; } }); } return setRootCSSVariables({ primary, neutral, text, outline, rounding, disabledOpacity, lightTheme, darkTheme, }); } /** * 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 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)), }; const mergedText = { xs: runtimeText.xs === undefined ? textXs : definedText.xs, sm: runtimeText.sm === undefined ? textSm : definedText.sm, md: runtimeText.md === undefined ? textMd : definedText.md, lg: runtimeText.lg === undefined ? textLg : definedText.lg, }; if (isCSR && text) { setCookie(storageKey.sm, String(mergedText.xs)); setCookie(storageKey.sm, String(mergedText.sm)); setCookie(storageKey.md, String(mergedText.md)); setCookie(storageKey.lg, String(mergedText.lg)); localStorage.setItem(storageKey.sm, 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 ThemeConfigText; const globalOutline = primitiveToObject(vuelessConfig.outline) as ThemeConfigText; 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)), }; const mergedOutline = { sm: runtimeOutline.sm === undefined ? outlineSm : definedOutline.sm, md: runtimeOutline.md === undefined ? outlineMd : definedOutline.md, lg: runtimeOutline.lg === undefined ? outlineLg : definedOutline.lg, }; if (isCSR && outline) { 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; // 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 */ 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)), }; /* eslint-enable prettier/prettier */ const mergedRounding = { sm: runtimeRounding.sm === undefined ? roundingSm : definedRounding.sm, md: runtimeRounding.md === undefined ? roundingMd : definedRounding.md, lg: runtimeRounding.lg === undefined ? roundingLg : definedRounding.lg, }; if (isCSR && rounding) { 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 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) { setCookie(storageKey, String(mergedOpacity)); localStorage.setItem(storageKey, String(mergedOpacity)); } return mergedOpacity; } /** * 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 }; } /** * 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-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; }