[WEB-3540] chore: icon color picker enhancements #6878
This commit is contained in:
parent
475b7a8396
commit
ac84d6ecf0
4 changed files with 128 additions and 140 deletions
|
|
@ -17,3 +17,128 @@ export const rgbToHex = (rgb: TRgb): string => {
|
||||||
|
|
||||||
return `#${hexR}${hexG}${hexB}`;
|
return `#${hexR}${hexG}${hexB}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate relative luminance of a color according to WCAG
|
||||||
|
* @param {Object} rgb - RGB color object with r, g, b properties
|
||||||
|
* @returns {number} Relative luminance value
|
||||||
|
*/
|
||||||
|
export function getLuminance({ r, g, b }: { r: number; g: number; b: number }) {
|
||||||
|
// Convert RGB to sRGB
|
||||||
|
const sR = r / 255;
|
||||||
|
const sG = g / 255;
|
||||||
|
const sB = b / 255;
|
||||||
|
|
||||||
|
// Convert sRGB to linear RGB with gamma correction
|
||||||
|
const R = sR <= 0.03928 ? sR / 12.92 : Math.pow((sR + 0.055) / 1.055, 2.4);
|
||||||
|
const G = sG <= 0.03928 ? sG / 12.92 : Math.pow((sG + 0.055) / 1.055, 2.4);
|
||||||
|
const B = sB <= 0.03928 ? sB / 12.92 : Math.pow((sB + 0.055) / 1.055, 2.4);
|
||||||
|
|
||||||
|
// Calculate luminance
|
||||||
|
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate contrast ratio between two colors
|
||||||
|
* @param {Object} rgb1 - First RGB color object
|
||||||
|
* @param {Object} rgb2 - Second RGB color object
|
||||||
|
* @returns {number} Contrast ratio between the colors
|
||||||
|
*/
|
||||||
|
export function getContrastRatio(rgb1: { r: number; g: number; b: number }, rgb2: { r: number; g: number; b: number }) {
|
||||||
|
const luminance1 = getLuminance(rgb1);
|
||||||
|
const luminance2 = getLuminance(rgb2);
|
||||||
|
|
||||||
|
const lighter = Math.max(luminance1, luminance2);
|
||||||
|
const darker = Math.min(luminance1, luminance2);
|
||||||
|
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lighten a color by a specified amount
|
||||||
|
* @param {Object} rgb - RGB color object
|
||||||
|
* @param {number} amount - Amount to lighten (0-1)
|
||||||
|
* @returns {Object} Lightened RGB color
|
||||||
|
*/
|
||||||
|
export function lightenColor(rgb: { r: number; g: number; b: number }, amount: number) {
|
||||||
|
return {
|
||||||
|
r: rgb.r + (255 - rgb.r) * amount,
|
||||||
|
g: rgb.g + (255 - rgb.g) * amount,
|
||||||
|
b: rgb.b + (255 - rgb.b) * amount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Darken a color by a specified amount
|
||||||
|
* @param {Object} rgb - RGB color object
|
||||||
|
* @param {number} amount - Amount to darken (0-1)
|
||||||
|
* @returns {Object} Darkened RGB color
|
||||||
|
*/
|
||||||
|
export function darkenColor(rgb: { r: number; g: number; b: number }, amount: number) {
|
||||||
|
return {
|
||||||
|
r: rgb.r * (1 - amount),
|
||||||
|
g: rgb.g * (1 - amount),
|
||||||
|
b: rgb.b * (1 - amount),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate appropriate foreground and background colors based on input color
|
||||||
|
* @param {string} color - Input color in hex format
|
||||||
|
* @returns {Object} Object containing foreground and background colors in hex format
|
||||||
|
*/
|
||||||
|
export function generateIconColors(color: string) {
|
||||||
|
// Parse input color
|
||||||
|
const rgbColor = hexToRgb(color);
|
||||||
|
const luminance = getLuminance(rgbColor);
|
||||||
|
|
||||||
|
// Initialize output colors
|
||||||
|
let foregroundColor = rgbColor;
|
||||||
|
|
||||||
|
// Constants for color adjustment
|
||||||
|
const MIN_CONTRAST_RATIO = 3.0; // Minimum acceptable contrast ratio
|
||||||
|
|
||||||
|
// For light colors, use as foreground and darken for background
|
||||||
|
if (luminance > 0.5) {
|
||||||
|
// Make sure the foreground color is dark enough for visibility
|
||||||
|
let adjustedForeground = foregroundColor;
|
||||||
|
const whiteContrast = getContrastRatio(foregroundColor, { r: 255, g: 255, b: 255 });
|
||||||
|
|
||||||
|
if (whiteContrast < MIN_CONTRAST_RATIO) {
|
||||||
|
// Darken the foreground color until it has enough contrast
|
||||||
|
let darkenAmount = 0.1;
|
||||||
|
while (darkenAmount <= 0.9) {
|
||||||
|
adjustedForeground = darkenColor(foregroundColor, darkenAmount);
|
||||||
|
if (getContrastRatio(adjustedForeground, { r: 255, g: 255, b: 255 }) >= MIN_CONTRAST_RATIO) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
darkenAmount += 0.1;
|
||||||
|
}
|
||||||
|
foregroundColor = adjustedForeground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// For dark colors, use as foreground and lighten for background
|
||||||
|
else {
|
||||||
|
// Make sure the foreground color is light enough for visibility
|
||||||
|
let adjustedForeground = foregroundColor;
|
||||||
|
const blackContrast = getContrastRatio(foregroundColor, { r: 0, g: 0, b: 0 });
|
||||||
|
|
||||||
|
if (blackContrast < MIN_CONTRAST_RATIO) {
|
||||||
|
// Lighten the foreground color until it has enough contrast
|
||||||
|
let lightenAmount = 0.1;
|
||||||
|
while (lightenAmount <= 0.9) {
|
||||||
|
adjustedForeground = lightenColor(foregroundColor, lightenAmount);
|
||||||
|
if (getContrastRatio(adjustedForeground, { r: 0, g: 0, b: 0 }) >= MIN_CONTRAST_RATIO) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
lightenAmount += 0.1;
|
||||||
|
}
|
||||||
|
foregroundColor = adjustedForeground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
foreground: rgbToHex({ r: foregroundColor.r, g: foregroundColor.g, b: foregroundColor.b }),
|
||||||
|
background: `rgba(${foregroundColor.r}, ${foregroundColor.g}, ${foregroundColor.b}, 0.25)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
import chroma from "chroma-js";
|
|
||||||
|
|
||||||
interface HSLColor {
|
|
||||||
h: number; // hue (0-360)
|
|
||||||
s: number; // saturation (0-100)
|
|
||||||
l: number; // lightness (0-100)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ColorAdjustmentOptions {
|
|
||||||
targetContrast?: number; // Minimum contrast ratio (4.5 for WCAG AAA, 3 for WCAG AA)
|
|
||||||
preserveHue?: boolean; // Whether to maintain the original hue
|
|
||||||
maxTries?: number; // Maximum attempts to find accessible colors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to ensure color contrast compliance
|
|
||||||
const ensureAccessibleColors = (
|
|
||||||
foreground: string,
|
|
||||||
background: string,
|
|
||||||
options: ColorAdjustmentOptions = {}
|
|
||||||
): { foreground: string; background: string } => {
|
|
||||||
const {
|
|
||||||
targetContrast = 4.5, // WCAG AAA by default
|
|
||||||
preserveHue = true,
|
|
||||||
maxTries = 10,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fg = chroma(foreground);
|
|
||||||
const bg = chroma(background);
|
|
||||||
let contrast = chroma.contrast(fg, bg);
|
|
||||||
|
|
||||||
// If contrast is already good, return original colors
|
|
||||||
if (contrast >= targetContrast) {
|
|
||||||
return { foreground, background };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Adjust colors to meet contrast requirements
|
|
||||||
let adjustedFg = fg;
|
|
||||||
let adjustedBg = bg;
|
|
||||||
let tries = 0;
|
|
||||||
|
|
||||||
while (contrast < targetContrast && tries < maxTries) {
|
|
||||||
if (fg.luminance() > bg.luminance()) {
|
|
||||||
// Make foreground lighter and background darker
|
|
||||||
adjustedFg = preserveHue ? fg.luminance(Math.min(fg.luminance() + 0.1, 0.9)) : fg.brighten(0.5);
|
|
||||||
adjustedBg = preserveHue ? bg.luminance(Math.max(bg.luminance() - 0.1, 0.1)) : bg.darken(0.5);
|
|
||||||
} else {
|
|
||||||
// Make foreground darker and background lighter
|
|
||||||
adjustedFg = preserveHue ? fg.luminance(Math.max(fg.luminance() - 0.1, 0.1)) : fg.darken(0.5);
|
|
||||||
adjustedBg = preserveHue ? bg.luminance(Math.min(bg.luminance() + 0.1, 0.9)) : bg.brighten(0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
contrast = chroma.contrast(adjustedFg, adjustedBg);
|
|
||||||
tries++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
foreground: adjustedFg.css(),
|
|
||||||
background: adjustedBg.css(),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.warn("Color adjustment failed:", error);
|
|
||||||
return { foreground, background };
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// background color
|
|
||||||
export const createBackgroundColor = (hsl: HSLColor, resolvedTheme: "light" | "dark" = "light"): string => {
|
|
||||||
const baseColor = chroma.hsl(hsl.h, hsl.s / 100, hsl.l / 100);
|
|
||||||
|
|
||||||
// Set base opacity according to theme
|
|
||||||
const baseOpacity = resolvedTheme === "dark" ? 0.25 : 0.15;
|
|
||||||
|
|
||||||
// Create semi-transparent background
|
|
||||||
let backgroundColor = baseColor.alpha(baseOpacity);
|
|
||||||
|
|
||||||
if (hsl.l > 90) {
|
|
||||||
backgroundColor = baseColor.darken(1).alpha(resolvedTheme === "dark" ? 0.3 : 0.2);
|
|
||||||
} else if (hsl.l > 70) {
|
|
||||||
backgroundColor = baseColor.darken(0.5).alpha(resolvedTheme === "dark" ? 0.28 : 0.18);
|
|
||||||
} else if (hsl.l < 30) {
|
|
||||||
backgroundColor = baseColor.brighten(0.5).alpha(resolvedTheme === "dark" ? 0.22 : 0.12);
|
|
||||||
}
|
|
||||||
|
|
||||||
return backgroundColor.css();
|
|
||||||
};
|
|
||||||
|
|
||||||
// foreground color
|
|
||||||
export const getIconColor = (hsl: HSLColor): string => {
|
|
||||||
const baseColor = chroma.hsl(hsl.h, hsl.s / 100, hsl.l / 100);
|
|
||||||
const backgroundColor = createBackgroundColor(hsl);
|
|
||||||
|
|
||||||
// Adjust colors for accessibility
|
|
||||||
const { foreground } = ensureAccessibleColors(baseColor.css(), backgroundColor, {
|
|
||||||
targetContrast: 3, // WCAG AA for UI components
|
|
||||||
preserveHue: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return foreground;
|
|
||||||
};
|
|
||||||
|
|
@ -39,9 +39,7 @@
|
||||||
"@plane/utils": "*",
|
"@plane/utils": "*",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@react-pdf/renderer": "^3.4.5",
|
"@react-pdf/renderer": "^3.4.5",
|
||||||
"@types/chroma-js": "^3.1.1",
|
|
||||||
"axios": "^1.8.3",
|
"axios": "^1.8.3",
|
||||||
"chroma-js": "^3.1.2",
|
|
||||||
"clsx": "^2.0.0",
|
"clsx": "^2.0.0",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"comlink": "^4.4.1",
|
"comlink": "^4.4.1",
|
||||||
|
|
|
||||||
41
yarn.lock
41
yarn.lock
|
|
@ -3162,11 +3162,6 @@
|
||||||
"@types/connect" "*"
|
"@types/connect" "*"
|
||||||
"@types/node" "*"
|
"@types/node" "*"
|
||||||
|
|
||||||
"@types/chroma-js@^3.1.1":
|
|
||||||
version "3.1.1"
|
|
||||||
resolved "https://registry.yarnpkg.com/@types/chroma-js/-/chroma-js-3.1.1.tgz#92cac57fb32d642ce156dbc4c052b5e3a3a25db1"
|
|
||||||
integrity sha512-SFCr4edNkZ1bGaLzGz7rgR1bRzVX4MmMxwsIa3/Bh6ose8v+hRpneoizHv0KChdjxaXyjRtaMq7sCuZSzPomQA==
|
|
||||||
|
|
||||||
"@types/compression@^1.7.5":
|
"@types/compression@^1.7.5":
|
||||||
version "1.7.5"
|
version "1.7.5"
|
||||||
resolved "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz#0f80efef6eb031be57b12221c4ba6bc3577808f7"
|
resolved "https://registry.npmjs.org/@types/compression/-/compression-1.7.5.tgz#0f80efef6eb031be57b12221c4ba6bc3577808f7"
|
||||||
|
|
@ -4766,11 +4761,6 @@ chownr@^1.1.1:
|
||||||
resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
|
resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz#6fc9d7b42d32a583596337666e7d08084da2cc6b"
|
||||||
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
|
integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==
|
||||||
|
|
||||||
chroma-js@^3.1.2:
|
|
||||||
version "3.1.2"
|
|
||||||
resolved "https://registry.yarnpkg.com/chroma-js/-/chroma-js-3.1.2.tgz#cfb807045182228574eae5380587cdb830e985d6"
|
|
||||||
integrity sha512-IJnETTalXbsLx1eKEgx19d5L6SRM7cH4vINw/99p/M11HCuXGRWL+6YmCm7FWFGIo6dtWuQoQi1dc5yQ7ESIHg==
|
|
||||||
|
|
||||||
chromatic@^11.4.0:
|
chromatic@^11.4.0:
|
||||||
version "11.25.2"
|
version "11.25.2"
|
||||||
resolved "https://registry.npmjs.org/chromatic/-/chromatic-11.25.2.tgz#cb93dc1332d8f6b70d97a3ef126bc6d03429d396"
|
resolved "https://registry.npmjs.org/chromatic/-/chromatic-11.25.2.tgz#cb93dc1332d8f6b70d97a3ef126bc6d03429d396"
|
||||||
|
|
@ -10549,16 +10539,7 @@ streamx@^2.15.0, streamx@^2.21.0:
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
bare-events "^2.2.0"
|
bare-events "^2.2.0"
|
||||||
|
|
||||||
"string-width-cjs@npm:string-width@^4.2.0":
|
"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
||||||
version "4.2.3"
|
|
||||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
|
||||||
dependencies:
|
|
||||||
emoji-regex "^8.0.0"
|
|
||||||
is-fullwidth-code-point "^3.0.0"
|
|
||||||
strip-ansi "^6.0.1"
|
|
||||||
|
|
||||||
string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
|
|
||||||
version "4.2.3"
|
version "4.2.3"
|
||||||
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
|
||||||
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
|
||||||
|
|
@ -10651,14 +10632,7 @@ string_decoder@^1.1.1, string_decoder@^1.3.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer "~5.2.0"
|
safe-buffer "~5.2.0"
|
||||||
|
|
||||||
"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
|
"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
||||||
version "6.0.1"
|
|
||||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
|
||||||
dependencies:
|
|
||||||
ansi-regex "^5.0.1"
|
|
||||||
|
|
||||||
strip-ansi@^6.0.0, strip-ansi@^6.0.1:
|
|
||||||
version "6.0.1"
|
version "6.0.1"
|
||||||
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
|
||||||
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
|
||||||
|
|
@ -11876,16 +11850,7 @@ word-wrap@^1.2.5:
|
||||||
resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
|
resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
|
||||||
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
|
||||||
|
|
||||||
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
|
"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
|
||||||
version "7.0.0"
|
|
||||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
|
||||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
|
||||||
dependencies:
|
|
||||||
ansi-styles "^4.0.0"
|
|
||||||
string-width "^4.1.0"
|
|
||||||
strip-ansi "^6.0.0"
|
|
||||||
|
|
||||||
wrap-ansi@^7.0.0:
|
|
||||||
version "7.0.0"
|
version "7.0.0"
|
||||||
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
|
||||||
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue