igniteui-theming
Version:
A set of Sass variables, mixins, and functions for generating palettes, typography, and elevations used by Ignite UI components.
301 lines (298 loc) • 9.94 kB
JavaScript
import { themingImporter } from "./theming-resolve.js";
import * as sass from "sass-embedded";
//#region src/utils/color.ts
/**
* Color analysis utilities using Sass-embedded.
* Calls the actual Sass luminance() and contrast() functions for accurate validation.
*/
/**
* Luminance threshold for determining light vs dark colors.
* Colors with luminance > 0.5 are considered "light".
* Colors with luminance <= 0.5 are considered "dark".
*/
var LUMINANCE_THRESHOLD = .5;
/**
* Suggested colors for different variants.
*/
var SUGGESTED_COLORS = {
light: {
surface: [
"white",
"#ffffff",
"#f8f9fa",
"#fafafa",
"#f5f5f5"
],
gray: [
"black",
"#000000",
"#333333",
"#212121",
"#424242"
]
},
dark: {
surface: [
"#222222",
"#1a1a1a",
"#121212",
"#181818",
"#2d2d2d"
],
gray: [
"white",
"#ffffff",
"#e5e5e5",
"#f5f5f5",
"#eeeeee"
]
}
};
/**
* Analyze a single color using Sass luminance() function.
*
* @param color - CSS color value (hex, rgb, hsl, or named color)
* @returns Color analysis with luminance and isLight flag
* @throws Error if Sass compilation fails or color is invalid
*/
async function analyzeColor(color) {
const sassCode = `
@use 'igniteui-theming/sass/color' as color;
$lum: color.luminance(${color});
:root {
--luminance: #{$lum};
}
`;
try {
const luminanceMatch = (await sass.compileStringAsync(sassCode, { importers: [themingImporter] })).css.match(/--luminance:\s*([\d.]+)/);
if (!luminanceMatch) throw new Error(`Could not parse luminance from Sass output for color: ${color}`);
const luminance = Number.parseFloat(luminanceMatch[1]);
return {
color,
luminance,
isLight: luminance > LUMINANCE_THRESHOLD
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to analyze color "${color}": ${message}`);
}
}
/**
* Analyze surface and gray colors together in a single Sass compilation.
* This is more efficient than calling analyzeColor twice.
*
* @param params - Object containing surface and/or gray colors
* @returns Combined analysis results
*/
async function analyzeSurfaceGrayColors(params) {
const { surface, gray } = params;
if (!surface && !gray) return {};
const sassLines = [`@use 'igniteui-theming/sass/color' as color;`, ""];
if (surface) sassLines.push(`$surface-lum: color.luminance(${surface});`);
if (gray) sassLines.push(`$gray-lum: color.luminance(${gray});`);
if (surface && gray) sassLines.push(`$contrast: color.contrast(${surface}, ${gray});`);
sassLines.push("", ":root {");
if (surface) sassLines.push(" --surface-luminance: #{$surface-lum};");
if (gray) sassLines.push(" --gray-luminance: #{$gray-lum};");
if (surface && gray) sassLines.push(" --contrast-ratio: #{$contrast};");
sassLines.push("}");
const sassCode = sassLines.join("\n");
try {
const result = await sass.compileStringAsync(sassCode, { importers: [themingImporter] });
const analysis = {};
if (surface) {
const surfaceMatch = result.css.match(/--surface-luminance:\s*([\d.]+)/);
if (surfaceMatch) {
const luminance = Number.parseFloat(surfaceMatch[1]);
analysis.surface = {
color: surface,
luminance,
isLight: luminance > LUMINANCE_THRESHOLD
};
}
}
if (gray) {
const grayMatch = result.css.match(/--gray-luminance:\s*([\d.]+)/);
if (grayMatch) {
const luminance = Number.parseFloat(grayMatch[1]);
analysis.gray = {
color: gray,
luminance,
isLight: luminance > LUMINANCE_THRESHOLD
};
}
}
if (surface && gray) {
const contrastMatch = result.css.match(/--contrast-ratio:\s*([\d.]+)/);
if (contrastMatch) analysis.contrastRatio = Number.parseFloat(contrastMatch[1]);
}
return analysis;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to analyze surface/gray colors: ${message}`);
}
}
/**
* Check if a color is valid by attempting to analyze it.
*
* @param color - CSS color value to validate
* @returns true if the color is valid, false otherwise
*/
async function isValidColor(color) {
try {
await analyzeColor(color);
return true;
} catch {
return false;
}
}
/**
* Validate multiple colors in a single Sass compilation for efficiency.
* This is much faster than calling isValidColor() for each color individually.
*
* @param colors - Map of key names to color values
* @returns Map of key names to validation results (true = valid, false = invalid)
*/
async function validateColorsInBatch(colors) {
const entries = Object.entries(colors);
if (entries.length === 0) return {};
const sassLines = [
`@use 'sass:color';`,
`@use 'sass:meta';`,
""
];
for (const [key, colorValue] of entries) {
const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-");
sassLines.push(`$${safeKey}-valid: meta.type-of(${colorValue}) == 'color';`);
}
sassLines.push("", ":root {");
for (const [key] of entries) {
const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-");
sassLines.push(` --${safeKey}-valid: #{$${safeKey}-valid};`);
}
sassLines.push("}");
const sassCode = sassLines.join("\n");
try {
const result = await sass.compileStringAsync(sassCode, { importers: [themingImporter] });
const validationResults = {};
for (const [key] of entries) {
const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-");
const validMatch = result.css.match(new RegExp(`--${safeKey}-valid:\\s*(true|false)`));
validationResults[key] = validMatch ? validMatch[1] === "true" : false;
}
return validationResults;
} catch {
const validationResults = {};
for (const [key, colorValue] of entries) validationResults[key] = await isValidColor(colorValue);
return validationResults;
}
}
/**
* Check if two hue values are within tolerance of each other,
* accounting for the circular nature of hue (0° = 360°).
*
* @param hue1 - First hue value (0-360)
* @param hue2 - Second hue value (0-360)
* @param tolerance - Maximum allowed difference in degrees (default: 30)
* @returns true if hues are within tolerance
*/
function huesAreClose(hue1, hue2, tolerance = 30) {
const diff = Math.abs(hue1 - hue2);
return Math.min(diff, 360 - diff) <= tolerance;
}
/**
* Analyze multiple colors in a single Sass compilation for efficiency.
* Returns luminance and hue for each color.
*
* @param colors - Map of key names to color values
* @returns Map of key names to analysis results
*/
async function analyzeColorsWithHue(colors) {
const entries = Object.entries(colors);
if (entries.length === 0) return {};
const sassLines = [
`@use 'igniteui-theming/sass/color' as igColor;`,
`@use 'sass:color';`,
""
];
for (const [key, color] of entries) {
const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-");
sassLines.push(`$${safeKey}-lum: igColor.luminance(${color});`);
sassLines.push(`$${safeKey}-hue: color.channel(${color}, "hue", $space: hsl);`);
}
sassLines.push("", ":root {");
for (const [key] of entries) {
const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-");
sassLines.push(` --${safeKey}-luminance: #{$${safeKey}-lum};`);
sassLines.push(` --${safeKey}-hue: #{$${safeKey}-hue};`);
}
sassLines.push("}");
const sassCode = sassLines.join("\n");
try {
const result = await sass.compileStringAsync(sassCode, { importers: [themingImporter] });
const analysis = {};
for (const [key] of entries) {
const safeKey = key.replace(/[^a-zA-Z0-9]/g, "-");
const lumMatch = result.css.match(new RegExp(`--${safeKey}-luminance:\\s*([\\d.]+)`));
const hueMatch = result.css.match(new RegExp(`--${safeKey}-hue:\\s*([\\d.]+)`));
if (lumMatch && hueMatch) analysis[key] = {
luminance: Number.parseFloat(lumMatch[1]),
hue: Number.parseFloat(hueMatch[1])
};
}
return analysis;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to analyze colors: ${message}`);
}
}
/**
* Luminance thresholds for palette shade generation suitability.
* Colors outside this range may produce poor automatic shade generation results.
*
* Note: This is different from LUMINANCE_THRESHOLD (0.5) which determines
* if a color is "light" or "dark" for variant matching. These thresholds
* determine if a color can produce a good range of shades when used with
* the palette() function.
*
* Based on color theory research:
* - Optimal base color tone: 35-65 L* (CIELAB)
* - Too light (L* > 70-75): darker shades compress together
* - Too dark (L* < 25-30): lighter shades compress together
*/
var PALETTE_LUMINANCE_THRESHOLDS = {
TOO_DARK: .05,
TOO_LIGHT: .45
};
/**
* Analyze whether a color is suitable for automatic shade generation.
* Colors with extreme luminance (very light or very dark) may produce
* poor results when using the palette() function's automatic shade generation.
*
* @param color - CSS color value (hex, rgb, hsl, or named color)
* @returns Analysis result indicating suitability and any issues
*/
async function analyzeColorForPalette(color) {
const analysis = await analyzeColor(color);
if (analysis.luminance > PALETTE_LUMINANCE_THRESHOLDS.TOO_LIGHT) return {
color,
luminance: analysis.luminance,
suitable: false,
issue: "too-light",
description: `Luminance ${analysis.luminance.toFixed(2)} exceeds ${PALETTE_LUMINANCE_THRESHOLDS.TOO_LIGHT} - darker shades (600-900) will appear washed out`
};
if (analysis.luminance < PALETTE_LUMINANCE_THRESHOLDS.TOO_DARK) return {
color,
luminance: analysis.luminance,
suitable: false,
issue: "too-dark",
description: `Luminance ${analysis.luminance.toFixed(2)} is below ${PALETTE_LUMINANCE_THRESHOLDS.TOO_DARK} - lighter shades (50-200) will lack contrast range`
};
return {
color,
luminance: analysis.luminance,
suitable: true
};
}
//#endregion
export { LUMINANCE_THRESHOLD, SUGGESTED_COLORS, analyzeColorForPalette, analyzeColorsWithHue, analyzeSurfaceGrayColors, huesAreClose, validateColorsInBatch };