diff --git a/projects/js-packages/charts/changelog/fix-CHARTS-196-funnel-chart-non-hex-colors b/projects/js-packages/charts/changelog/fix-CHARTS-196-funnel-chart-non-hex-colors new file mode 100644 index 000000000000..63a9fe055fe3 --- /dev/null +++ b/projects/js-packages/charts/changelog/fix-CHARTS-196-funnel-chart-non-hex-colors @@ -0,0 +1,4 @@ +Significance: patch +Type: fixed + +Charts: Fix conversion funnel chart crash when using non-hex color formats. diff --git a/projects/js-packages/charts/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx b/projects/js-packages/charts/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx index a6996939c664..d3dc6332b796 100644 --- a/projects/js-packages/charts/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx +++ b/projects/js-packages/charts/src/charts/conversion-funnel-chart/conversion-funnel-chart.tsx @@ -11,7 +11,7 @@ import { useGlobalChartsTheme, useGlobalChartsContext, } from '../../providers'; -import { formatPercentage, hexToRgba } from '../../utils'; +import { formatPercentage, colorToRgba } from '../../utils'; import styles from './conversion-funnel-chart.module.scss'; import { useFunnelSelection } from './private'; import type { FunnelStep, ConversionFunnelChartProps } from './types'; @@ -247,7 +247,7 @@ const ConversionFunnelChartInternal: FC< ConversionFunnelChartProps > = ( { // Create light background version of primary color if not set const barBackgroundColor = - backgroundColor || hexToRgba( barColor, 0.08 ) || 'rgba(0, 0, 0, 0.08)'; + backgroundColor || colorToRgba( barColor, 0.08 ) || 'rgba(0, 0, 0, 0.08)'; // Default main metric rendering function const renderDefaultMainMetric = () => ( diff --git a/projects/js-packages/charts/src/charts/leaderboard-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/leaderboard-chart/stories/index.stories.tsx index 4ca1af9e4170..8f3d5a8e1c8d 100644 --- a/projects/js-packages/charts/src/charts/leaderboard-chart/stories/index.stories.tsx +++ b/projects/js-packages/charts/src/charts/leaderboard-chart/stories/index.stories.tsx @@ -13,7 +13,7 @@ import { themeArgTypes, } from '../../../stories'; import { legendArgTypes, extractLegendConfig } from '../../../stories/legend-config'; -import { formatMetricValue, hexToRgba } from '../../../utils'; +import { formatMetricValue, colorToRgba } from '../../../utils'; import LeaderboardChart from '../leaderboard-chart'; import type { Meta, StoryObj } from '@storybook/react'; @@ -349,7 +349,7 @@ const LeaderboardChartWithOverlayLabelImage = ( args: StoryArgs ) => { overrideColor: args.primaryColor, } ); - const primaryColorWithAlpha = hexToRgba( primaryColor, 0.08 ); + const primaryColorWithAlpha = colorToRgba( primaryColor, 0.08 ); return ; }; diff --git a/projects/js-packages/charts/src/utils/color-utils.ts b/projects/js-packages/charts/src/utils/color-utils.ts index 50fe36bd39d7..2026b7e8744e 100644 --- a/projects/js-packages/charts/src/utils/color-utils.ts +++ b/projects/js-packages/charts/src/utils/color-utils.ts @@ -32,11 +32,34 @@ export const validateHexColor = ( hex: unknown ): void => { throw new Error( 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' ); }; +/** + * Convert any valid CSS color to rgba with specified opacity. + * Gracefully handles any CSS color format (hex, rgb, hsl, named colors, etc.) + * and returns null for unparseable values instead of throwing. + * + * @param color - Any valid CSS color string (e.g., '#ff0000', 'rgb(255, 0, 0)', 'red') + * @param alpha - The opacity value. Values outside the [0, 1] range will be clamped by d3. + * @return The rgba color string (e.g., 'rgba(255, 0, 0, 0.5)'), or null if the color is invalid + */ +export const colorToRgba = ( color: string, alpha: number ): string | null => { + if ( typeof color !== 'string' || typeof alpha !== 'number' || isNaN( alpha ) ) { + return null; + } + + const parsed = d3Color( color ); + if ( ! parsed ) { + return null; + } + + return parsed.copy( { opacity: alpha } ).formatRgb(); +}; + /** * Convert hex color to rgba with specified opacity. - * This is genuinely reusable across chart components. + * + * @deprecated Use `colorToRgba` instead, which accepts any CSS color format and returns null instead of throwing. * @param hex - The hex color string (e.g., '#ff0000') - * @param alpha - The opacity value. Values outside the [0, 1] range will be clamped by the underlying d3 color library. + * @param alpha - The opacity value. Values outside the [0, 1] range will be clamped by d3. * @return The rgba color string (e.g., 'rgba(255, 0, 0, 0.5)') * @throws {Error} if hex string is malformed or alpha is not a valid number */ @@ -47,8 +70,7 @@ export const hexToRgba = ( hex: string, alpha: number ): string => { throw new Error( 'Alpha must be a number' ); } - // Safe to use non-null assertion since validateHexColor ensures valid hex - return d3Color( hex )!.copy( { opacity: alpha } ).formatRgb(); + return colorToRgba( hex, alpha ) as string; }; /** diff --git a/projects/js-packages/charts/src/utils/test/color-utils.test.ts b/projects/js-packages/charts/src/utils/test/color-utils.test.ts index 4c2f2eb89a4f..7c2ec4b1fb50 100644 --- a/projects/js-packages/charts/src/utils/test/color-utils.test.ts +++ b/projects/js-packages/charts/src/utils/test/color-utils.test.ts @@ -1,9 +1,10 @@ import { hsl as d3Hsl } from '@visx/vendor/d3-color'; import { + colorToRgba, getColorDistance, + hexToRgba, lightenHexColor, isValidHexColor, - hexToRgba, validateHexColor, parseHslString, parseRgbString, @@ -291,234 +292,56 @@ describe( 'getColorDistance', () => { } ); } ); -describe( 'hexToRgba', () => { - describe( 'Valid hex colors', () => { - it( 'converts 6-digit hex to rgb with full opacity', () => { - const result = hexToRgba( '#ff0000', 1 ); - expect( result ).toBe( 'rgb(255, 0, 0)' ); - } ); - - it( 'converts 6-digit hex to rgba with partial opacity', () => { - const result = hexToRgba( '#00ff00', 0.5 ); - expect( result ).toBe( 'rgba(0, 255, 0, 0.5)' ); - } ); - - it( 'converts 6-digit hex to rgba with zero opacity', () => { - const result = hexToRgba( '#0000ff', 0 ); - expect( result ).toBe( 'rgba(0, 0, 255, 0)' ); - } ); - - it( 'handles lowercase hex colors', () => { - const result = hexToRgba( '#abcdef', 0.8 ); - expect( result ).toBe( 'rgba(171, 205, 239, 0.8)' ); - } ); - - it( 'handles uppercase hex colors', () => { - const result = hexToRgba( '#ABCDEF', 0.8 ); - expect( result ).toBe( 'rgba(171, 205, 239, 0.8)' ); - } ); - - it( 'handles mixed case hex colors', () => { - const result = hexToRgba( '#AbCdEf', 0.3 ); - expect( result ).toBe( 'rgba(171, 205, 239, 0.3)' ); - } ); +describe( 'colorToRgba', () => { + it( 'converts hex colors', () => { + expect( colorToRgba( '#ff0000', 0.5 ) ).toBe( 'rgba(255, 0, 0, 0.5)' ); } ); - describe( 'Edge cases', () => { - it( 'handles black color', () => { - const result = hexToRgba( '#000000', 1 ); - expect( result ).toBe( 'rgb(0, 0, 0)' ); - } ); - - it( 'handles white color', () => { - const result = hexToRgba( '#ffffff', 1 ); - expect( result ).toBe( 'rgb(255, 255, 255)' ); - } ); - - it( 'handles high precision alpha values', () => { - const result = hexToRgba( '#ff0000', 0.123456 ); - expect( result ).toBe( 'rgba(255, 0, 0, 0.123456)' ); - } ); - - // Function now validates hex input format - it( 'throws error for hex without # prefix', () => { - expect( () => hexToRgba( 'ff0000', 1 ) ).toThrow( 'Hex color must start with #' ); - } ); - } ); - - describe( 'Input validation', () => { - describe( 'Invalid hex format', () => { - it( 'throws error for non-string input', () => { - expect( () => hexToRgba( 123 as unknown as string, 1 ) ).toThrow( - 'Hex color must be a string' - ); - expect( () => hexToRgba( null as unknown as string, 1 ) ).toThrow( - 'Hex color must be a string' - ); - expect( () => hexToRgba( undefined as unknown as string, 1 ) ).toThrow( - 'Hex color must be a string' - ); - } ); - - it( 'throws error for hex without # prefix', () => { - expect( () => hexToRgba( 'ff0000', 1 ) ).toThrow( 'Hex color must start with #' ); - expect( () => hexToRgba( '000000', 1 ) ).toThrow( 'Hex color must start with #' ); - } ); - - it( 'throws error for wrong length hex strings', () => { - expect( () => hexToRgba( '#ff', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - expect( () => hexToRgba( '#fff', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - expect( () => hexToRgba( '#ffff', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - expect( () => hexToRgba( '#fffff', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - expect( () => hexToRgba( '#ff00000', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - expect( () => hexToRgba( '#', 1 ) ).toThrow( - 'Hex color must be 7 characters long (e.g., #ff0000)' - ); - } ); - - it( 'throws error for invalid hex characters', () => { - expect( () => hexToRgba( '#gggggg', 1 ) ).toThrow( - 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' - ); - expect( () => hexToRgba( '#ff00gg', 1 ) ).toThrow( - 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' - ); - expect( () => hexToRgba( '#zz0000', 1 ) ).toThrow( - 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' - ); - expect( () => hexToRgba( '#ff@000', 1 ) ).toThrow( - 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' - ); - expect( () => hexToRgba( '#ff 000', 1 ) ).toThrow( - 'Hex color contains invalid characters. Only 0-9, a-f, A-F are allowed' - ); - } ); - - it( 'throws error for empty string', () => { - expect( () => hexToRgba( '', 1 ) ).toThrow( 'Hex color must start with #' ); - } ); - } ); - - describe( 'Invalid alpha values', () => { - it( 'throws error for non-number alpha', () => { - expect( () => hexToRgba( '#ff0000', 'invalid' as unknown as number ) ).toThrow( - 'Alpha must be a number' - ); - expect( () => hexToRgba( '#ff0000', null as unknown as number ) ).toThrow( - 'Alpha must be a number' - ); - expect( () => hexToRgba( '#ff0000', undefined as unknown as number ) ).toThrow( - 'Alpha must be a number' - ); - expect( () => hexToRgba( '#ff0000', {} as unknown as number ) ).toThrow( - 'Alpha must be a number' - ); - } ); - - it( 'throws error for NaN alpha', () => { - expect( () => hexToRgba( '#ff0000', NaN ) ).toThrow( 'Alpha must be a number' ); - } ); - - it( 'accepts negative and greater than 1 alpha values without throwing (d3 color formatRgb() clamps them)', () => { - // These should not throw - CSS allows alpha values outside 0-1 range - expect( () => hexToRgba( '#ff0000', -0.5 ) ).not.toThrow(); - expect( () => hexToRgba( '#ff0000', 1.5 ) ).not.toThrow(); - expect( () => hexToRgba( '#ff0000', 2 ) ).not.toThrow(); - } ); - } ); + it( 'converts named CSS colors', () => { + expect( colorToRgba( 'red', 0.5 ) ).toBe( 'rgba(255, 0, 0, 0.5)' ); } ); - describe( 'Real-world color examples', () => { - it( 'converts primary blue color', () => { - const result = hexToRgba( '#4f46e5', 0.08 ); - expect( result ).toBe( 'rgba(79, 70, 229, 0.08)' ); - } ); - - it( 'converts success green color', () => { - const result = hexToRgba( '#10b981', 0.15 ); - expect( result ).toBe( 'rgba(16, 185, 129, 0.15)' ); - } ); - - it( 'converts error red color', () => { - const result = hexToRgba( '#ef4444', 0.2 ); - expect( result ).toBe( 'rgba(239, 68, 68, 0.2)' ); - } ); - - it( 'converts gray color', () => { - const result = hexToRgba( '#6b7280', 0.6 ); - expect( result ).toBe( 'rgba(107, 114, 128, 0.6)' ); - } ); + it( 'converts rgb() colors', () => { + expect( colorToRgba( 'rgb(0, 128, 255)', 0.3 ) ).toBe( 'rgba(0, 128, 255, 0.3)' ); } ); - describe( 'Boundary alpha values', () => { - it( 'handles alpha value of 0', () => { - const result = hexToRgba( '#ff0000', 0 ); - expect( result ).toBe( 'rgba(255, 0, 0, 0)' ); - } ); - - it( 'handles alpha value of 1 (returns rgb format)', () => { - const result = hexToRgba( '#ff0000', 1 ); - expect( result ).toBe( 'rgb(255, 0, 0)' ); - } ); - - it( 'clamps negative alpha values to 0', () => { - const result = hexToRgba( '#ff0000', -0.5 ); - expect( result ).toBe( 'rgba(255, 0, 0, 0)' ); - } ); + it( 'converts hsl() colors', () => { + const result = colorToRgba( 'hsl(0, 100%, 50%)', 0.8 ); + expect( result ).toBe( 'rgba(255, 0, 0, 0.8)' ); + } ); - it( 'clamps alpha values greater than 1 (returns rgb format)', () => { - const result = hexToRgba( '#ff0000', 1.5 ); - expect( result ).toBe( 'rgb(255, 0, 0)' ); - } ); + it( 'converts 3-digit hex colors', () => { + expect( colorToRgba( '#f00', 0.5 ) ).toBe( 'rgba(255, 0, 0, 0.5)' ); } ); - describe( 'Color component extraction', () => { - it( 'correctly extracts red component', () => { - const result = hexToRgba( '#ff0000', 1 ); - expect( result ).toContain( '255, 0, 0' ); - } ); + it( 'returns null for invalid color strings', () => { + expect( colorToRgba( 'not-a-color', 0.5 ) ).toBeNull(); + } ); - it( 'correctly extracts green component', () => { - const result = hexToRgba( '#00ff00', 1 ); - expect( result ).toContain( '0, 255, 0' ); - } ); + it( 'returns null for empty string', () => { + expect( colorToRgba( '', 0.5 ) ).toBeNull(); + } ); - it( 'correctly extracts blue component', () => { - const result = hexToRgba( '#0000ff', 1 ); - expect( result ).toContain( '0, 0, 255' ); - } ); + it( 'returns null for non-string input', () => { + expect( colorToRgba( 123 as unknown as string, 0.5 ) ).toBeNull(); + } ); - it( 'correctly extracts all components for mixed color', () => { - const result = hexToRgba( '#8a2be2', 1 ); // BlueViolet - expect( result ).toBe( 'rgb(138, 43, 226)' ); - } ); + it( 'returns null for NaN alpha', () => { + expect( colorToRgba( '#ff0000', NaN ) ).toBeNull(); } ); +} ); - describe( 'Typical usage patterns', () => { - it( 'works with common CSS background opacity', () => { - const result = hexToRgba( '#4f46e5', 0.08 ); - expect( result ).toBe( 'rgba(79, 70, 229, 0.08)' ); - } ); +describe( 'hexToRgba (deprecated)', () => { + it( 'converts hex to rgba', () => { + expect( hexToRgba( '#ff0000', 0.5 ) ).toBe( 'rgba(255, 0, 0, 0.5)' ); + } ); - it( 'works with hover state opacity', () => { - const result = hexToRgba( '#4f46e5', 0.15 ); - expect( result ).toBe( 'rgba(79, 70, 229, 0.15)' ); - } ); + it( 'throws for invalid hex', () => { + expect( () => hexToRgba( 'not-hex', 0.5 ) ).toThrow( 'Hex color must start with #' ); + } ); - it( 'works with disabled state opacity', () => { - const result = hexToRgba( '#4f46e5', 0.3 ); - expect( result ).toBe( 'rgba(79, 70, 229, 0.3)' ); - } ); + it( 'throws for invalid alpha', () => { + expect( () => hexToRgba( '#ff0000', NaN ) ).toThrow( 'Alpha must be a number' ); } ); } );