Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Charts: Fix conversion funnel chart crash when using non-hex color formats.
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = () => (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -349,7 +349,7 @@ const LeaderboardChartWithOverlayLabelImage = ( args: StoryArgs ) => {
overrideColor: args.primaryColor,
} );

const primaryColorWithAlpha = hexToRgba( primaryColor, 0.08 );
const primaryColorWithAlpha = colorToRgba( primaryColor, 0.08 );

return <LeaderboardChart { ...args } primaryColor={ primaryColorWithAlpha } />;
};
Expand Down
30 changes: 26 additions & 4 deletions projects/js-packages/charts/src/utils/color-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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;
};

/**
Expand Down
249 changes: 36 additions & 213 deletions projects/js-packages/charts/src/utils/test/color-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { hsl as d3Hsl } from '@visx/vendor/d3-color';
import {
colorToRgba,
getColorDistance,
hexToRgba,
lightenHexColor,
isValidHexColor,
hexToRgba,
validateHexColor,
parseHslString,
parseRgbString,
Expand Down Expand Up @@ -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' );
} );
} );

Expand Down
Loading