Skip to content

Commit

Permalink
feat(react-swatch-picker): added contrast ratio calculation for icon …
Browse files Browse the repository at this point in the history
…contrast
  • Loading branch information
ValentinaKozlova committed May 14, 2024
1 parent f709b68 commit 9758d36
Show file tree
Hide file tree
Showing 10 changed files with 302 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,18 @@ import * as React_2 from 'react';
import type { Slot } from '@fluentui/react-utilities';
import type { SlotClassNames } from '@fluentui/react-utilities';

// @public
export function calculateContrastRatio(l1: number, l2: number): number;

// @public (undocumented)
export function calculateContrastRatioFromHex(hex1: string, hex2: string): number | undefined;

// @public (undocumented)
export function calculateContrastRatioFromRgb(rgb1: Rgb, rgb2: Rgb): number | undefined;

// @public
export function calculateRelativeLuminance(rgb: Rgb): number | undefined;

// @public
export const ColorSwatch: ForwardRefComponent<ColorSwatchProps>;

Expand All @@ -24,6 +36,7 @@ export const colorSwatchClassNames: SlotClassNames<ColorSwatchSlots>;

// @public
export type ColorSwatchProps = ComponentProps<ColorSwatchSlots> & Pick<SwatchPickerProps, 'size' | 'shape'> & {
borderColor?: string;
color: string;
disabled?: boolean;
value: string;
Expand All @@ -41,6 +54,9 @@ export type ColorSwatchState = ComponentState<ColorSwatchSlots> & Pick<ColorSwat
selected: boolean;
};

// @public (undocumented)
export function convertColorToRgb(color: string): Rgb;

// @public
export const EmptySwatch: ForwardRefComponent<EmptySwatchProps>;

Expand All @@ -58,6 +74,12 @@ export type EmptySwatchSlots = {
// @public
export type EmptySwatchState = ComponentState<EmptySwatchSlots> & Pick<EmptySwatchProps, 'size' | 'shape'>;

// @public (undocumented)
export function getContrastRatio(color1: string, color2: string): number | undefined;

// @public
export function hexToRgb(hex: string): Rgb;

// @public
export const ImageSwatch: ForwardRefComponent<ImageSwatchProps>;

Expand Down Expand Up @@ -98,9 +120,13 @@ export const renderSwatchPickerGrid: (props: SwatchPickerGridProps) => JSX.Eleme
// @public
export const renderSwatchPickerRow_unstable: (state: SwatchPickerRowState) => JSX.Element;

// @public (undocumented)
export type Rgb = [number, number, number] | null;

// @public (undocumented)
export const swatchCSSVars: {
color: string;
borderColor: string;
};

// @public
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export type ColorSwatchSlots = {
*/
export type ColorSwatchProps = ComponentProps<ColorSwatchSlots> &
Pick<SwatchPickerProps, 'size' | 'shape'> & {
/**
* Border color when contrast is low
*/
borderColor?: string;

/**
* Swatch color
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { ColorSwatchProps, ColorSwatchState } from './ColorSwatch.types';
import { useSwatchPickerContextValue_unstable } from '../../contexts/swatchPicker';
import { swatchCSSVars } from './useColorSwatchStyles.styles';
import { ProhibitedFilled } from '@fluentui/react-icons';
import { calculateContrastRatioFromHex } from '../../utils/contrastUtils';
import { tokens } from '@fluentui/react-theme';

/**
* Create the state required to render ColorSwatch.
Expand All @@ -18,14 +20,17 @@ export const useColorSwatch_unstable = (
props: ColorSwatchProps,
ref: React.Ref<HTMLButtonElement>,
): ColorSwatchState => {
const { color, disabled, disabledIcon, icon, value, onClick, size, shape, style, ...rest } = props;
const { borderColor, color, disabled, disabledIcon, icon, value, onClick, size, shape, style, ...rest } = props;
const _size = useSwatchPickerContextValue_unstable(ctx => ctx.size);
const _shape = useSwatchPickerContextValue_unstable(ctx => ctx.shape);
const isGrid = useSwatchPickerContextValue_unstable(ctx => ctx.isGrid);

const requestSelectionChange = useSwatchPickerContextValue_unstable(ctx => ctx.requestSelectionChange);
const selected = useSwatchPickerContextValue_unstable(ctx => ctx.selectedValue === value);

const contrastRatio = calculateContrastRatioFromHex(color, '#FFFFFF');
const disabledContrastColor = contrastRatio && contrastRatio < 3 ? '#000000' : '#FFFFFF';

const onColorSwatchClick = useEventCallback(
mergeCallbacks(onClick, (event: React.MouseEvent<HTMLButtonElement>) =>
requestSelectionChange(event, {
Expand All @@ -37,6 +42,7 @@ export const useColorSwatch_unstable = (

const rootVariables = {
[swatchCSSVars.color]: color,
[swatchCSSVars.borderColor]: borderColor ?? tokens.colorTransparentStroke,
};

const role = isGrid ? 'gridcell' : 'radio';
Expand All @@ -49,7 +55,7 @@ export const useColorSwatch_unstable = (
const iconShorthand = slot.optional(icon, { elementType: 'span' });
const disabledIconShorthand = slot.optional(disabledIcon, {
defaultProps: {
children: <ProhibitedFilled />,
children: <ProhibitedFilled color={disabledContrastColor} />,
},
renderByDefault: true,
elementType: 'span',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@ export const colorSwatchClassNames: SlotClassNames<ColorSwatchSlots> = {

export const swatchCSSVars = {
color: `--fui-SwatchPicker--color`,
borderColor: `--fui-SwatchPicker--borderColor`,
};

const { color } = swatchCSSVars;
const { color, borderColor } = swatchCSSVars;

/**
* Styles for the root slot
Expand All @@ -25,7 +26,7 @@ const useResetStyles = makeResetStyles({
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
border: `1px solid ${tokens.colorTransparentStroke}`,
border: `1px solid var(${borderColor})`,
background: `var(${color})`,
overflow: 'hidden',
padding: '0',
Expand Down Expand Up @@ -130,9 +131,10 @@ const useShapeStyles = makeStyles({

const useIconStyles = makeStyles({
disabledIcon: {
color: tokens.colorNeutralForegroundInverted,
// color: tokens.colorNeutralForegroundInverted,
},
icon: {
position: 'absolute',
display: 'flex',
alignSelf: 'center',
},
Expand Down
10 changes: 10 additions & 0 deletions packages/react-components/react-swatch-picker-preview/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ export {
} from './SwatchPickerRow';
export { renderSwatchPickerGrid } from './utils/renderUtils';
export type { SwatchProps, SwatchPickerGridProps } from './utils/renderUtils';
export {
calculateContrastRatio,
calculateContrastRatioFromHex,
calculateContrastRatioFromRgb,
calculateRelativeLuminance,
convertColorToRgb,
getContrastRatio,
hexToRgb,
} from './utils/contrastUtils';
export type { Rgb } from './utils/contrastUtils';
export {
EmptySwatch,
renderEmptySwatch_unstable,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
calculateRelativeLuminance,
hexToRgb,
calculateContrastRatio,
calculateContrastRatioFromRgb,
calculateContrastRatioFromHex,
} from './contrastUtils';

describe('calculateRelativeLuminance', () => {
it('should calculate the relative luminance correctly', () => {
expect(calculateRelativeLuminance(null)).toBe(undefined);
expect(calculateRelativeLuminance([0, 0, 0])).toBe(0);
expect(calculateRelativeLuminance([255, 255, 255])).toBeCloseTo(1, 5);
expect(calculateRelativeLuminance([171, 255, 124])).toBeCloseTo(0.81633, 5);
});
it('should convert hex to rgb correctly', () => {
expect(hexToRgb('')).toBe(null);
expect(hexToRgb('#')).toBe(null);
expect(hexToRgb('red')).toBe(null);
expect(hexToRgb('#fff')).toBe(null);
expect(hexToRgb('#000000')).toStrictEqual([0, 0, 0]);
expect(hexToRgb('#ffffff')).toStrictEqual([255, 255, 255]);
expect(hexToRgb('#abff7c')).toStrictEqual([171, 255, 124]);
});

it('should calculate the contrast ratio correctly', () => {
expect(calculateContrastRatio(0, 0)).toBe(1);
expect(calculateContrastRatio(0, 1)).toBeCloseTo(21, 5);
expect(calculateContrastRatio(1, 0)).toBeCloseTo(21, 5);
expect(calculateContrastRatio(0.5, 0.5)).toBe(1);
expect(calculateContrastRatio(0.5, 0.75)).toBeCloseTo(1.45, 2);
expect(calculateContrastRatio(0.75, 0.5)).toBeCloseTo(1.45, 2);
});

it('should calculate the contrast ratio correctly from RGB', () => {
expect(calculateContrastRatioFromRgb([0, 0, 0], [0, 0, 0])).toBe(1);
expect(calculateContrastRatioFromRgb([0, 0, 0], [255, 255, 255])).toBeCloseTo(21, 5);
expect(calculateContrastRatioFromRgb([255, 255, 255], [0, 0, 0])).toBeCloseTo(21, 5);
expect(calculateContrastRatioFromRgb([128, 128, 128], [128, 128, 128])).toBe(1);
expect(calculateContrastRatioFromRgb([128, 128, 128], [192, 192, 192])).toBeCloseTo(2.17, 2);
expect(calculateContrastRatioFromRgb([255, 255, 255], [255, 25, 33])).toBeCloseTo(3.88, 2);
});

it('should calculate the contrast ratio correctly from hex', () => {
expect(calculateContrastRatioFromHex('#000000', '#000000')).toBe(1);
expect(calculateContrastRatioFromHex('#000000', '#FFFFFF')).toBeCloseTo(21, 5);
expect(calculateContrastRatioFromHex('#FFFFFF', '#000000')).toBeCloseTo(21, 5);
expect(calculateContrastRatioFromHex('#808080', '#808080')).toBe(1);
expect(calculateContrastRatioFromHex('#808080', '#C0C0C0')).toBeCloseTo(2.17, 2);
expect(calculateContrastRatioFromHex('#FFFFFF', '#FF1921')).toBeCloseTo(3.88, 2);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
export type Rgb = [number, number, number] | null;

export function convertColorToRgb(color: string): Rgb {
if (color.includes('rgb') || color.includes('rgba')) {
const rgb = color.replace(/[^\d,]/g, '').split(',');
return [parseInt(rgb[0], 10), parseInt(rgb[1], 10), parseInt(rgb[2], 10)];
}
if (color.includes('#')) {
return hexToRgb(color);
}
return null;
}

/**
* Converts hex value to decimal and returns RGB value.
*
* @param hex - color in hex format
*
* @returns RGB value or null if the hex is invalid
*/
export function hexToRgb(hex: string): Rgb {
if (!hex) {
return null;
}

hex = hex.replace('#', '');

if (hex.length === 6) {
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);

return [r, g, b];
}
return null;
}

/**
* Gets color in RGB and calculates Relative Luminance.
* For more details see reference: https://www.w3.org/TR/WCAG20/#relativeluminancedef
*
* @param rgb - color in RGB format
*
* @returns relative luminance or undefined if the RGB is invalid
*/
export function calculateRelativeLuminance(rgb: Rgb): number | undefined {
if (!rgb) {
return;
}
const sR = rgb[0] / 255;
const sG = rgb[1] / 255;
const sB = rgb[2] / 255;

const gammaCorrectedR = sR <= 0.03928 ? sR / 12.92 : Math.pow((sR + 0.055) / 1.055, 2.4);
const gammaCorrectedG = sG <= 0.03928 ? sG / 12.92 : Math.pow((sG + 0.055) / 1.055, 2.4);
const gammaCorrectedB = sB <= 0.03928 ? sB / 12.92 : Math.pow((sB + 0.055) / 1.055, 2.4);

const relativeLuminance = 0.2126 * gammaCorrectedR + 0.7152 * gammaCorrectedG + 0.0722 * gammaCorrectedB;

return relativeLuminance;
}

/**
* Calculates contrast ratio between two colors.
* Returned value will be in the range [1, 21]
* 1 is no contrast, 21 is max contrast
* For more details see reference: https://www.w3.org/TR/WCAG20/#contrast-ratiodef
*
* @param l1 - relative luminance of the first color
* @param l2 - relative luminance of the second color
*
* @returns contrast ratio
*/
export function calculateContrastRatio(l1: number, l2: number): number {
const lighterColorL1 = Math.max(l1, l2);
const darkerColorL2 = Math.min(l1, l2);
const LUMINANCE_THRESHOLD = 0.05;

const contrastRatio = (lighterColorL1 + LUMINANCE_THRESHOLD) / (darkerColorL2 + LUMINANCE_THRESHOLD);

return contrastRatio;
}

export function calculateContrastRatioFromRgb(rgb1: Rgb, rgb2: Rgb): number | undefined {
if (!rgb1 || !rgb2) {
return;
}

const l1 = calculateRelativeLuminance(rgb1);
const l2 = calculateRelativeLuminance(rgb2);

if ((l1 || l1 === 0) && (l2 || l2 === 0)) {
return calculateContrastRatio(l1, l2);
}
return;
}

export function calculateContrastRatioFromHex(hex1: string, hex2: string): number | undefined {
const rgb1 = hexToRgb(hex1);
const rgb2 = hexToRgb(hex2);
return calculateContrastRatioFromRgb(rgb1, rgb2);
}

export function getContrastRatio(color1: string, color2: string): number | undefined {
const rgb1 = convertColorToRgb(color1);
const rgb2 = convertColorToRgb(color2);
return calculateContrastRatioFromRgb(rgb1, rgb2);
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,18 @@ export const ColorSwatchVariants = () => {
<ColorSwatch color="purple" value="purple-color" aria-label="Purple" />
<ColorSwatch color="#E3008C" value="hot-pink-color" aria-label="Hot pink" />
<ColorSwatch color="linear-gradient(0deg, #E3008C, #fff232)" value="gradient" aria-label="Gradient yellow pink" />
<ColorSwatch color="#c8eeff" icon={<HeartFilled color="red" />} value="icon" aria-label="heart-icon" />
<ColorSwatch disabled color="#c8eeff" icon={<HeartFilled color="red" />} value="icon" aria-label="heart-icon" />
<ColorSwatch color="#016ab0" disabled value="blue" aria-label="blue" />
<ColorSwatch disabled color="#ff659a" value="initials" aria-label="initials">
A
</ColorSwatch>
<ColorSwatch color="#ff659a" value="initials" aria-label="initials">
A
</ColorSwatch>
<ColorSwatch icon={<HeartFilled color="red" />} color="#ff659a" value="initials" aria-label="initials">
A
</ColorSwatch>
<ColorSwatch disabled color="#c8eeff" icon={<HeartFilled color="red" />} value="icon" aria-label="heart-icon" />
</div>
);
};
Expand Down
Loading

0 comments on commit 9758d36

Please sign in to comment.