Skip to content
66 changes: 66 additions & 0 deletions pages/avatar/custom-width.page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import { useState } from "react";

import Checkbox from "@cloudscape-design/components/checkbox";
import FormField from "@cloudscape-design/components/form-field";
import Header from "@cloudscape-design/components/header";
import { IconProps } from "@cloudscape-design/components/icon";
import Input from "@cloudscape-design/components/input";
import SpaceBetween from "@cloudscape-design/components/space-between";

import { Avatar } from "../../lib/components";

export default function AvatarImageAndWidth() {
const [url, setURL] = useState(
"https://static1.colliderimages.com/wordpress/wp-content/uploads/2024/08/deadpool-wolverine-hugh-jackman-mask-reveal.jpg",
);
const [width, setWidth] = useState("100");
const [initials, setInitials] = useState("SO");
const [iconName, setIconName] = useState("search");
const [loading, setLoading] = useState(false);
const [genAI, setGenAI] = useState(false);

return (
<SpaceBetween direction="vertical" size="m">
<Header>Input an Image URL and custom size.</Header>

<SpaceBetween alignItems="center" direction="horizontal" size="m">
<FormField label="Width:">
<Input onChange={({ detail }) => setWidth(detail.value)} value={width} inputMode="numeric" type="number" />
</FormField>

<FormField label="Image URL">
<Input onChange={({ detail }) => setURL(detail.value)} value={url} />
</FormField>

<FormField label="Initials">
<Input onChange={({ detail }) => setInitials(detail.value)} value={initials} />
</FormField>

<FormField label="Icon Name">
<Input onChange={({ detail }) => setIconName(detail.value)} value={iconName} />
</FormField>

<Checkbox onChange={({ detail }) => setLoading(detail.checked)} checked={loading}>
Loading
</Checkbox>

<Checkbox onChange={({ detail }) => setGenAI(detail.checked)} checked={genAI}>
Gen AI
</Checkbox>
</SpaceBetween>

<Avatar
ariaLabel="Various Avatar permutations"
color={genAI ? "gen-ai" : "default"}
iconName={iconName as IconProps.Name}
imgUrl={url}
width={Number(width)}
initials={initials}
loading={loading}
/>
</SpaceBetween>
);
}
61 changes: 61 additions & 0 deletions pages/avatar/permutations.page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0

import SpaceBetween from "@cloudscape-design/components/space-between";

import { Avatar } from "../../lib/components";
import { TestBed } from "../app/test-bed";
import { ScreenshotArea } from "../screenshot-area";
import smiley from "./smiley.png";

const customIconSvg = (
<svg
Expand Down Expand Up @@ -38,15 +41,73 @@ export default function AvatarPage() {
<Avatar color="gen-ai" initials="GW" ariaLabel="Gen AI assistant GW" tooltipText="Gen AI assistant" />

<Avatar loading={true} ariaLabel="User avatar typing" tooltipText="User avatar typing" />

{/* Loading should take prioirty over image */}
<Avatar
color="gen-ai"
loading={true}
imgUrl={smiley}
ariaLabel="Gen AI assistant generating response"
tooltipText="Gen AI assistant generating response"
/>

<Avatar iconSvg={customIconSvg} ariaLabel="Avatar with custom SVG icon" />
<Avatar color="gen-ai" iconSvg={customIconSvg} ariaLabel="Gen AI avatar with custom SVG icon" />

<br />

<SpaceBetween direction="vertical" size="xxs">
{/* Image with tiny width enforce minimum of 28px */}
<Avatar ariaLabel="An awesome picture of smiley" imgUrl={smiley} width={20} />

{/* Image with default width of 28px */}
<Avatar ariaLabel="An awesome picture of smiley" imgUrl={smiley} />

{/* Image should take priority over initials */}
<Avatar ariaLabel="An awesome picture of smiley" initials="WV" imgUrl={smiley} width={40} />

{/* Image and tooltip should take priority over icon */}
<Avatar
ariaLabel="An awesome picture of smiley"
tooltipText="Snikt!"
imgUrl={smiley}
iconSvg={customIconSvg}
width={60}
/>

{/* Icon SVG with custom width */}
<Avatar ariaLabel="Avatar with custom SVG icon" iconSvg={customIconSvg} width={60} />

{/* Icon name with custom width */}
<Avatar color="gen-ai" iconName="gen-ai" ariaLabel="Gen AI assistant" width={80} />

{/* Icon name with custom width */}
<Avatar iconName="calendar" ariaLabel="Gen AI assistant" width={100} />

{/* Initials with custom width */}
<Avatar
color="gen-ai"
initials="GW"
ariaLabel="Gen AI assistant GW"
tooltipText="Gen AI assistant"
width={140}
/>

{/* Loading with custom width */}
<Avatar color="gen-ai" initials="GW" loading={true} ariaLabel="Gen AI assistant GW" width={160} />

{/* Initials with custom width */}
<Avatar
color="gen-ai"
initials="GW"
ariaLabel="Gen AI assistant GW"
tooltipText="Gen AI assistant"
width={180}
/>

{/* Loading with custom width */}
<Avatar color="gen-ai" initials="GW" loading={true} ariaLabel="Gen AI assistant GW" width={200} />
</SpaceBetween>
</TestBed>
</main>
</ScreenshotArea>
Expand Down
Binary file added pages/avatar/smiley.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 17 additions & 7 deletions src/__tests__/__snapshots__/documenter.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,18 @@ If you set both \`iconName\` and \`initials\`, \`initials\` will take precedence
"type": "string",
},
{
"description": "Specifies the URL of a custom icon. Use this property if the icon you want isn't available, and your custom icon can't be an SVG.
For SVG icons, use the \`iconSvg\` slot instead.
If you set both \`iconUrl\` and \`iconSvg\`, \`iconSvg\` will take precedence.
",
"deprecatedTag": "Use \`iconSvg\` or \`imgUrl\` instead.",
"description": "Specifies the URL of a custom icon. Use this property if the icon you want isn't available, and your custom icon can't be an SVG.",
"name": "iconUrl",
"optional": true,
"type": "string",
},
{
"description": "Specifies the URL of a custom image. If you set both \`iconUrl\` and \`imgUrl\`, \`imgUrl\` will take precedence.",
"name": "imgUrl",
"optional": true,
"type": "string",
},
{
"description": "The text content shown directly in the avatar's body.
Can be 1 or 2 symbols long, every subsequent symbol is ignored.
Expand All @@ -78,13 +82,19 @@ When you use this property, make sure to include it in the \`ariaLabel\`.
"optional": true,
"type": "string",
},
{
"description": "Defines the width and height of the avatar.
This value corresponds to the \`width\` CSS-property and will center and crop images using \`object-fit: cover\`.
The default and minimum width value is 28px.",
"name": "width",
"optional": true,
"type": "number",
},
],
"regions": [
{
"description": "Specifies the SVG of a custom icon.
Use this property if the icon you want isn't available.
If you set both \`iconUrl\` and \`iconSvg\`, \`iconSvg\` will take precedence.
",
Use this property if the icon you want isn't available.",
"isDefault": false,
"name": "iconSvg",
},
Expand Down
19 changes: 19 additions & 0 deletions src/avatar/__tests__/avatar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import Avatar, { AvatarProps } from "../../../lib/components/avatar";
import createWrapper from "../../../lib/components/test-utils/dom";

import loadingDotsStyles from "../../../lib/components/avatar/loading-dots/styles.selectors.js";
import avatarStyles from "../../../lib/components/avatar/styles.selectors.js";

const defaultAvatarProps: AvatarProps = { ariaLabel: "Avatar" };

Expand All @@ -30,6 +31,15 @@ describe("Avatar", () => {
expect(wrapper.getElement().textContent).toBe(initials);
});

test("Renders avatar with image", () => {
const imgUrl =
"https://static1.colliderimages.com/wordpress/wp-content/uploads/2024/08/deadpool-wolverine-hugh-jackman-mask-reveal.jpg";
const wrapper = renderAvatar({ ...defaultAvatarProps, imgUrl });

const image = wrapper.findByClassName(avatarStyles.image)?.getElement();
expect(image).toBeInTheDocument();
});

test("Shows tooltip on focus", () => {
const tooltipText = "Jane Doe";
const wrapper = renderAvatar({ ...defaultAvatarProps, color: "default", tooltipText });
Expand Down Expand Up @@ -75,6 +85,15 @@ describe("Avatar", () => {
expect(loading).toBeInTheDocument();
});

test("Loading takes precedence over image", () => {
const imgUrl =
"https://static1.colliderimages.com/wordpress/wp-content/uploads/2024/08/deadpool-wolverine-hugh-jackman-mask-reveal.jpg";
const wrapper = renderAvatar({ ...defaultAvatarProps, imgUrl, loading: true });

const loading = wrapper.findByClassName(loadingDotsStyles.root)?.getElement();
expect(loading).toBeInTheDocument();
});

test("Shows warning when initials length is longer than 2", () => {
const warnOnce = vi.spyOn(ComponentToolkitInternal, "warnOnce");

Expand Down
18 changes: 13 additions & 5 deletions src/avatar/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,19 +47,27 @@ export interface AvatarProps {

/**
* Specifies the URL of a custom icon. Use this property if the icon you want isn't available, and your custom icon can't be an SVG.
* For SVG icons, use the `iconSvg` slot instead.
*
* If you set both `iconUrl` and `iconSvg`, `iconSvg` will take precedence.
* @deprecated Use `iconSvg` or `imgUrl` instead.
*/
iconUrl?: string;

/**
* Specifies the SVG of a custom icon.
*
* Use this property if the icon you want isn't available.
* If you set both `iconUrl` and `iconSvg`, `iconSvg` will take precedence.
*/
iconSvg?: React.ReactNode;

/**
* Specifies the URL of a custom image. If you set both `iconUrl` and `imgUrl`, `imgUrl` will take precedence.
*/
imgUrl?: string;

/**
* Defines the width and height of the avatar.
* This value corresponds to the `width` CSS-property and will center and crop images using `object-fit: cover`.
* The default and minimum width value is 28px.
*/
width?: number;
}

export namespace AvatarProps {
Expand Down
35 changes: 30 additions & 5 deletions src/avatar/internal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import clsx from "clsx";
import { warnOnce } from "@cloudscape-design/component-toolkit/internal";
import Icon from "@cloudscape-design/components/icon";
import Tooltip from "@cloudscape-design/components/internal/tooltip-do-not-use";
import * as awsui from "@cloudscape-design/design-tokens";

import { getDataAttributes } from "../internal/base-component/get-data-attributes";
import { InternalBaseComponentProps } from "../internal/base-component/use-base-component";
Expand All @@ -17,9 +18,23 @@ import styles from "./styles.css.js";

export interface InternalAvatarProps extends AvatarProps, InternalBaseComponentProps {}

const AvatarContent = ({ color, loading, initials, iconName, iconSvg, iconUrl, ariaLabel }: AvatarProps) => {
const AvatarContent = ({
color,
loading,
initials,
iconName,
iconSvg,
iconUrl,
ariaLabel,
width,
imgUrl,
}: AvatarProps) => {
if (loading) {
return <LoadingDots color={color} />;
return <LoadingDots color={color} width={width} />;
}

if (imgUrl) {
return <img className={styles.image} src={imgUrl} style={{ height: width, width: width }} />;
}

if (initials) {
Expand All @@ -29,10 +44,14 @@ const AvatarContent = ({ color, loading, initials, iconName, iconSvg, iconUrl, a
warnOnce("Avatar", `"initials" is longer than 2 characters. Only the first two characters are shown.`);
}

return <span>{letters}</span>;
return (
<span style={{ fontSize: `clamp(${awsui.fontSizeBodyS}, calc(0.4px * ${width}), calc(0.4px * ${width}))` }}>
{letters}
</span>
);
}

return <Icon name={iconName} svg={iconSvg} url={iconUrl} alt={ariaLabel} />;
return <Icon name={iconName} svg={iconSvg} url={iconUrl} alt={ariaLabel} size="inherit" />;
};

export default function InternalAvatar({
Expand All @@ -44,13 +63,16 @@ export default function InternalAvatar({
iconName,
iconSvg,
iconUrl,
imgUrl,
width = 28,
__internalRootRef = null,
...rest
}: InternalAvatarProps) {
const handleRef = useRef<HTMLDivElement>(null);
const [showTooltip, setShowTooltip] = useState(false);

const mergedRef = useMergeRefs(handleRef, __internalRootRef);
const computedSize = width < 28 ? 28 : width;

const tooltipAttributes = {
onFocus: () => {
Expand Down Expand Up @@ -84,6 +106,7 @@ export default function InternalAvatar({
role="img"
aria-label={ariaLabel}
{...tooltipAttributes}
style={{ height: computedSize, width: computedSize }}
>
{showTooltip && tooltipText && (
<Tooltip
Expand All @@ -96,7 +119,7 @@ export default function InternalAvatar({

{/* aria-hidden is added so that screen readers focus only the parent div */}
{/* when it is not hidden, it becomes unstable in JAWS */}
<div className={styles.content} aria-hidden="true">
<div className={styles.content} aria-hidden="true" style={{ lineHeight: `calc(.8px * ${computedSize})` }}>
<AvatarContent
color={color}
ariaLabel={ariaLabel}
Expand All @@ -105,6 +128,8 @@ export default function InternalAvatar({
iconName={iconName}
iconSvg={iconSvg}
iconUrl={iconUrl}
imgUrl={imgUrl}
width={computedSize}
/>
</div>
</div>
Expand Down
11 changes: 7 additions & 4 deletions src/avatar/loading-dots/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,19 @@ import styles from "./styles.css.js";

interface LoadingDotsProps {
color?: string;
width?: number;
}

export default function LoadingDots({ color }: LoadingDotsProps) {
export default function LoadingDots({ color, width }: LoadingDotsProps) {
const dotSize = `calc(.14px * ${width})`;

return (
// "gen-ai" class is added so that the gradient background animates.
<div className={clsx(styles.root, { [styles["gen-ai"]]: color === "gen-ai" })}>
<div className={styles.typing}>
<div className={styles.dot}></div>
<div className={styles.dot}></div>
<div className={styles.dot}></div>
<div className={styles.dot} style={{ blockSize: dotSize, inlineSize: dotSize }}></div>
<div className={styles.dot} style={{ blockSize: dotSize, inlineSize: dotSize }}></div>
<div className={styles.dot} style={{ blockSize: dotSize, inlineSize: dotSize }}></div>
</div>
</div>
);
Expand Down
Loading
Loading