Skip to content

Commit

Permalink
feat(portrait): allows customers to provide a custom background colou…
Browse files Browse the repository at this point in the history
…r for the Portrait component
  • Loading branch information
damienrobson-sage committed Jan 30, 2025
1 parent 0e9595d commit fb61bcd
Show file tree
Hide file tree
Showing 14 changed files with 768 additions and 25 deletions.
118 changes: 118 additions & 0 deletions src/components/portrait/__internal__/utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import getColoursForPortrait from "./utils";

test("returns the default string if no arguments are passed", () => {
const result = getColoursForPortrait(undefined);
expect(result).toBe(
`background-color: var(--colorsUtilityReadOnly400); color: var(--colorsUtilityYin090);`,
);
});

test("returns a fixed string if only the `backgroundColor` argument is set to true", () => {
const result = getColoursForPortrait("#FF0000");
expect(result).toBe(
"background-color: #FF0000; color: var(--colorsUtilityYin090);",
);
});

test("returns a fixed string if the `darkBackground` argument is set to true", () => {
const result = getColoursForPortrait(undefined, true);
expect(result).toBe(
"background-color: var(--colorsUtilityYin090); color: var(--colorsUtilityReadOnly600);",
);
});

test("returns a fixed string if neither `darkBackground` nor `backgroundColor` argument are defined", () => {
const result = getColoursForPortrait(undefined, false);
expect(result).toBe(
`background-color: var(--colorsUtilityReadOnly400); color: var(--colorsUtilityYin090);`,
);
});

test("returns a string with the custom background color if the `backgroundColor` argument is defined", () => {
const result = getColoursForPortrait("#FF0000", false);
expect(result).toBe(
"background-color: #FF0000; color: var(--colorsUtilityYin090);",
);
});

test("returns a string with the custom background color if only the `backgroundColor` argument is defined and all others are false", () => {
const result = getColoursForPortrait("#FF0000", false, false, false);
expect(result).toBe(
"background-color: #FF0000; color: var(--colorsUtilityYin090);",
);
});

test("returns a string with the custom background color if the `backgroundColor` argument is defined and `largeText` argument is true", () => {
const result = getColoursForPortrait("#FF0000", false, true);
expect(result).toBe(
"background-color: #FF0000; color: var(--colorsUtilityYin090);",
);
});

test("returns a string with the custom background color if the `backgroundColor` and `largeText` arguments are defined and `strict` argument is false", () => {
const result = getColoursForPortrait("#FF0000", false, true, false);
expect(result).toBe(
"background-color: #FF0000; color: var(--colorsUtilityYin090);",
);
});

test("returns a string with the custom background color if the `backgroundColor` and `largeText` arguments are defined and `strict` argument is true", () => {
const result = getColoursForPortrait("#FF0000", false, true, true);
expect(result).toBe(
"background-color: #FF0000; color: var(--colorsUtilityYin090);",
);
});

describe("Contrast ratio tests", () => {
it("uses a white foreground colour if the white contrast ratio meets the minimum contrast threshold and is higher than the black contrast ratio", () => {
const result = getColoursForPortrait("#0000FF");
expect(result).toBe("background-color: #0000FF; color: #FFFFFF;");
});

it("uses a black foreground colour if the black contrast ratio meets the minimum contrast threshold", () => {
const result = getColoursForPortrait("#FFFF00");
expect(result).toBe(
"background-color: #FFFF00; color: var(--colorsUtilityYin090);",
);
});
});

test("returns a string with the custom background color and light text if the `backgroundColor` argument is set to a colour with poor contrast ratios (higher white contrast)", () => {
const result = getColoursForPortrait("#0000FF");
expect(result).toBe("background-color: #0000FF; color: #FFFFFF;");
});

test("returns a string with the custom colors if the `backgroundColor` and `foregroundColor` arguments are provided and all others are false", () => {
const result = getColoursForPortrait(
"#FF0000",
false,
false,
false,
"#00FF00",
);
expect(result).toBe("background-color: #FF0000; color: #00FF00;");
});

test("returns a string with the custom foreground color if `foregroundColor` argument is present but `backgroundColor` is omitted", () => {
const result = getColoursForPortrait(
undefined,
false,
false,
false,
"#00FF00",
);
expect(result).toBe(
`background-color: var(--colorsUtilityReadOnly400); color: #00FF00;`,
);
});

test("returns a string with the custom colors if the `darkBackground`, `foregroundColor` and `backgroundColor` props are set", () => {
const result = getColoursForPortrait(
"#FF0000",
true,
false,
false,
"#00FF00",
);
expect(result).toBe("background-color: #FF0000; color: #00FF00;");
});
102 changes: 102 additions & 0 deletions src/components/portrait/__internal__/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
const getContrastRatio = (luminance1: number, luminance2: number): number => {
const [L1, L2] =
luminance1 > luminance2
? [luminance1, luminance2]
: [luminance2, luminance1];
return (L1 + 0.05) / (L2 + 0.05);
};

const calculateLuminance = (hexColor: string): number => {
const hex = hexColor.replace("#", "");
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);

const normalize = (value: number): number => {
const v = value / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
};

const normalizedR = normalize(r);
const normalizedG = normalize(g);
const normalizedB = normalize(b);

const luminance =
0.2126 * normalizedR + 0.7152 * normalizedG + 0.0722 * normalizedB;

return luminance;
};

function getAccessibleForegroundColor(
backgroundColor: string,
largeText: boolean,
strict: boolean,
): string {
const bgLuminance = calculateLuminance(backgroundColor);
const whiteLuminance = calculateLuminance("#FFFFFF");
const blackLuminance = calculateLuminance("#000000");

const whiteContrast = getContrastRatio(bgLuminance, whiteLuminance);
const blackContrast = getContrastRatio(bgLuminance, blackLuminance);

const strictThreshold = largeText ? 4.5 : 7.0;
const nonStrictThreshold = largeText ? 3.0 : 4.5;
const minContrast = strict ? strictThreshold : nonStrictThreshold;

/* istanbul ignore else */
if (whiteContrast >= minContrast && whiteContrast > blackContrast) {
return "#FFFFFF";
}

/* istanbul ignore else */
if (blackContrast >= minContrast) {
return "var(--colorsUtilityYin090)";
}

// If no color meets the contrast ratio, return the color with the highest contrast
// In theory this is possible only if the background color is a shade of grey, but
// this is a fallback mechanism as finding a colour which fails both contrast ratios
// is highly unlikely.
/* istanbul ignore next */
return whiteContrast > blackContrast
? "#FFFFFF"
: "var(--colorsUtilityYin090)";
}

const getColoursForPortrait = (
// The custom background colour, if any
backgroundColour: string | undefined,
// Whether the portrait is on a dark background
darkBackground = false,
// Whether the text is large
largeText = false,
/**
* Whether to use strict contrast (i.e., WCAG AAA). If this is false, it uses WCAG AA contrast
* ratios (4.5:1 for normal text, 3:1 for large text). If true, it uses 7:1 for normal text and
* 4.5:1 for large text.
*/
strict = false,
// The custom foreground colour, if any
foregroundColor: string | undefined = undefined,
): string => {
let fgColor = "var(--colorsUtilityYin090)";
let bgColor = "var(--colorsUtilityReadOnly400)";

if (darkBackground && !backgroundColour && !foregroundColor) {
bgColor = "var(--colorsUtilityYin090)";
fgColor = "var(--colorsUtilityReadOnly600)";
}

if (backgroundColour) {
bgColor = backgroundColour;
fgColor = getAccessibleForegroundColor(backgroundColour, largeText, strict);
}

if (foregroundColor) {
fgColor = foregroundColor;
}

return `background-color: ${bgColor}; color: ${fgColor};`;
};

export default getColoursForPortrait;
36 changes: 35 additions & 1 deletion src/components/portrait/portrait-test.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Portrait, { PortraitProps } from "./portrait.component";

export default {
title: "Portrait/Test",
includeStories: ["Default"],
includeStories: ["Default", "CustomColors"],
parameters: {
info: { disable: true },
chromatic: {
Expand All @@ -32,6 +32,16 @@ export default {
type: "select",
},
},
backgroundColor: {
control: {
type: "color",
},
},
foregroundColor: {
control: {
type: "color",
},
},
},
};

Expand All @@ -50,6 +60,30 @@ Default.args = {
shape: "circle",
};

export const CustomColors = ({
backgroundColor,
foregroundColor,
...args
}: PortraitProps) => (
<Portrait
onClick={action("click")}
backgroundColor={backgroundColor}
foregroundColor={foregroundColor}
{...args}
/>
);

CustomColors.storyName = "Custom Colors";
CustomColors.args = {
src: "",
initials: "",
iconType: "accessibility_web",
size: "M",
shape: "circle",
backgroundColor: "#000000",
foregroundColor: "#FFFFFF",
};

export const PortraitDefaultComponent = ({ ...props }) => {
return <Portrait {...props} />;
};
Expand Down
10 changes: 10 additions & 0 deletions src/components/portrait/portrait.component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,16 @@ export interface PortraitProps extends MarginProps {
tooltipBgColor?: string;
/** [Legacy] Override font color of the Tooltip, provide any color from palette or any valid css color value. */
tooltipFontColor?: string;
/** The hex code of the background colour */
backgroundColor?: string;
/** The hex code of the foreground colour. This will only take effect if use in conjunction with `backgroundColor` */
foregroundColor?: string;
}

const Portrait = ({
alt,
backgroundColor,
foregroundColor = undefined,
name,
darkBackground = false,
iconType = "individual",
Expand Down Expand Up @@ -125,6 +131,8 @@ const Portrait = ({
darkBackground={darkBackground}
size={size}
shape={shape}
backgroundColor={backgroundColor}
foregroundColor={foregroundColor}
>
{portrait}
</StyledPortraitContainer>
Expand All @@ -141,6 +149,8 @@ const Portrait = ({
darkBackground={darkBackground}
size={size}
shape={shape}
backgroundColor={backgroundColor}
foregroundColor={foregroundColor}
>
{portrait}
</StyledPortraitContainer>
Expand Down
36 changes: 30 additions & 6 deletions src/components/portrait/portrait.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,53 +34,77 @@ import Portrait from "carbon-react/lib/components/portrait";

### Default

By default, the `Portrait` will render a circle with a user icon.

<Canvas of={PortraitStories.Default} />

### Initials

Basic way of using the `Portrait` component is to simply pass your initials as `initials` prop.
The `Portrait` component can also render initials in place of the icon if the `initials` prop is provided.

<Canvas of={PortraitStories.Initials} />

### Src

To use an image, simply pass any valid image URL as a `src` prop.
To use an image instead of the default icon or initials, pass any valid image URL via the `src` prop.

<Canvas of={PortraitStories.Src} />

### IconType

Portrait also supports the declaration of a icon to fallback on when `src` or `initials` are falsy.
`Portrait` allows you to specify an icon, which will be shown if the `src` or `initials` props are omitted.

<Canvas of={PortraitStories.IconType} />

### With tooltip

By providing a node element to the `tooltipMessage` prop, a tooltip can be displayed when hovering over the Portrait.
A tooltip can be displayed when hovering over the `Portrait` by providing the `tooltipMessage`, `tooltipPosition` and `tooltipColor` props.

<Canvas of={PortraitStories.WithTooltip} />

### Sizes

The `Portrait` component can be rendered in a variety of sizes by passing the desired size as the `size` prop.

<Canvas of={PortraitStories.Sizes} />

### Shapes

The `Portrait` component can be rendered in a variety of shapes by passing the desired shape as the `shape` prop.

<Canvas of={PortraitStories.Shapes} />

### Dark background

The `Portrait` component can be rendered with a dark background by passing the `darkBackground` prop.

<Canvas of={PortraitStories.DarkBackground} />

### With margin

Margins can be applied to the `portrait` component using styled-system.
To see a full list of available margin props, please visit the props table at the bottom of this page.
Margins can be applied to the `Portrait` component using styled-system. To see a full list of available margin props, please visit the props
table at the bottom of this page.

[Vist Props Table](#props)

<Canvas of={PortraitStories.WithMargin} />

### Custom colors

The `Portrait` component provides a set of props that allow for custom foreground and background colors
to be applied. These props are `backgroundColor` and `foregroundColor`, and both accept a HEX color code or
[design system token](https://zeroheight.com/2ccf2b601/p/217e24-design-tokens/b/870b8a).

Using these props will override the default colors of the `Portrait` component. They will also bypass the
`darkBackground` prop; setting it alongside `backgroundColor` or `forgroundColor` will have no effect.

When a `backgroundColor` is provided, the `foregroundColor` will be automatically set to a contrasting color.
This is calculated internally to ensure accessibility standards are met. If a custom `foregroundColor` is provided,
this value will be used instead of the calculated one; please ensure that the color contrast is sufficient and that
the component remains accessible.

<Canvas of={PortraitStories.CustomColors} />

## Props

### Portrait
Expand Down
Loading

0 comments on commit fb61bcd

Please sign in to comment.