Skip to content

Commit 8920006

Browse files
authored
feat: Add support for Avatar sizes and images. (#39)
* Initial commit. * Add smiley. * Update interface. * Update interface. * Update interface and add test page. * Resolve type error. * Added unit tests. * Resolve bug. * Remove file reference. * Added default value in props. * Minor update to interface. * Update documenter. * Updates to interface. * Update documenter.
1 parent 4b4c80a commit 8920006

File tree

11 files changed

+230
-28
lines changed

11 files changed

+230
-28
lines changed

pages/avatar/custom-width.page.tsx

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { useState } from "react";
5+
6+
import Checkbox from "@cloudscape-design/components/checkbox";
7+
import FormField from "@cloudscape-design/components/form-field";
8+
import Header from "@cloudscape-design/components/header";
9+
import { IconProps } from "@cloudscape-design/components/icon";
10+
import Input from "@cloudscape-design/components/input";
11+
import SpaceBetween from "@cloudscape-design/components/space-between";
12+
13+
import { Avatar } from "../../lib/components";
14+
15+
export default function AvatarImageAndWidth() {
16+
const [url, setURL] = useState(
17+
"https://static1.colliderimages.com/wordpress/wp-content/uploads/2024/08/deadpool-wolverine-hugh-jackman-mask-reveal.jpg",
18+
);
19+
const [width, setWidth] = useState("100");
20+
const [initials, setInitials] = useState("SO");
21+
const [iconName, setIconName] = useState("search");
22+
const [loading, setLoading] = useState(false);
23+
const [genAI, setGenAI] = useState(false);
24+
25+
return (
26+
<SpaceBetween direction="vertical" size="m">
27+
<Header>Input an Image URL and custom size.</Header>
28+
29+
<SpaceBetween alignItems="center" direction="horizontal" size="m">
30+
<FormField label="Width:">
31+
<Input onChange={({ detail }) => setWidth(detail.value)} value={width} inputMode="numeric" type="number" />
32+
</FormField>
33+
34+
<FormField label="Image URL">
35+
<Input onChange={({ detail }) => setURL(detail.value)} value={url} />
36+
</FormField>
37+
38+
<FormField label="Initials">
39+
<Input onChange={({ detail }) => setInitials(detail.value)} value={initials} />
40+
</FormField>
41+
42+
<FormField label="Icon Name">
43+
<Input onChange={({ detail }) => setIconName(detail.value)} value={iconName} />
44+
</FormField>
45+
46+
<Checkbox onChange={({ detail }) => setLoading(detail.checked)} checked={loading}>
47+
Loading
48+
</Checkbox>
49+
50+
<Checkbox onChange={({ detail }) => setGenAI(detail.checked)} checked={genAI}>
51+
Gen AI
52+
</Checkbox>
53+
</SpaceBetween>
54+
55+
<Avatar
56+
ariaLabel="Various Avatar permutations"
57+
color={genAI ? "gen-ai" : "default"}
58+
iconName={iconName as IconProps.Name}
59+
imgUrl={url}
60+
width={Number(width)}
61+
initials={initials}
62+
loading={loading}
63+
/>
64+
</SpaceBetween>
65+
);
66+
}

pages/avatar/permutations.page.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
// SPDX-License-Identifier: Apache-2.0
33

4+
import SpaceBetween from "@cloudscape-design/components/space-between";
5+
46
import { Avatar } from "../../lib/components";
57
import { TestBed } from "../app/test-bed";
68
import { ScreenshotArea } from "../screenshot-area";
9+
import smiley from "./smiley.png";
710

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

4043
<Avatar loading={true} ariaLabel="User avatar typing" tooltipText="User avatar typing" />
44+
45+
{/* Loading should take prioirty over image */}
4146
<Avatar
4247
color="gen-ai"
4348
loading={true}
49+
imgUrl={smiley}
4450
ariaLabel="Gen AI assistant generating response"
4551
tooltipText="Gen AI assistant generating response"
4652
/>
4753

4854
<Avatar iconSvg={customIconSvg} ariaLabel="Avatar with custom SVG icon" />
4955
<Avatar color="gen-ai" iconSvg={customIconSvg} ariaLabel="Gen AI avatar with custom SVG icon" />
56+
57+
<br />
58+
59+
<SpaceBetween direction="vertical" size="xxs">
60+
{/* Image with tiny width enforce minimum of 28px */}
61+
<Avatar ariaLabel="An awesome picture of smiley" imgUrl={smiley} width={20} />
62+
63+
{/* Image with default width of 28px */}
64+
<Avatar ariaLabel="An awesome picture of smiley" imgUrl={smiley} />
65+
66+
{/* Image should take priority over initials */}
67+
<Avatar ariaLabel="An awesome picture of smiley" initials="WV" imgUrl={smiley} width={40} />
68+
69+
{/* Image and tooltip should take priority over icon */}
70+
<Avatar
71+
ariaLabel="An awesome picture of smiley"
72+
tooltipText="Snikt!"
73+
imgUrl={smiley}
74+
iconSvg={customIconSvg}
75+
width={60}
76+
/>
77+
78+
{/* Icon SVG with custom width */}
79+
<Avatar ariaLabel="Avatar with custom SVG icon" iconSvg={customIconSvg} width={60} />
80+
81+
{/* Icon name with custom width */}
82+
<Avatar color="gen-ai" iconName="gen-ai" ariaLabel="Gen AI assistant" width={80} />
83+
84+
{/* Icon name with custom width */}
85+
<Avatar iconName="calendar" ariaLabel="Gen AI assistant" width={100} />
86+
87+
{/* Initials with custom width */}
88+
<Avatar
89+
color="gen-ai"
90+
initials="GW"
91+
ariaLabel="Gen AI assistant GW"
92+
tooltipText="Gen AI assistant"
93+
width={140}
94+
/>
95+
96+
{/* Loading with custom width */}
97+
<Avatar color="gen-ai" initials="GW" loading={true} ariaLabel="Gen AI assistant GW" width={160} />
98+
99+
{/* Initials with custom width */}
100+
<Avatar
101+
color="gen-ai"
102+
initials="GW"
103+
ariaLabel="Gen AI assistant GW"
104+
tooltipText="Gen AI assistant"
105+
width={180}
106+
/>
107+
108+
{/* Loading with custom width */}
109+
<Avatar color="gen-ai" initials="GW" loading={true} ariaLabel="Gen AI assistant GW" width={200} />
110+
</SpaceBetween>
50111
</TestBed>
51112
</main>
52113
</ScreenshotArea>

pages/avatar/smiley.png

133 KB
Loading

src/__tests__/__snapshots__/documenter.test.ts.snap

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -48,14 +48,18 @@ If you set both \`iconName\` and \`initials\`, \`initials\` will take precedence
4848
"type": "string",
4949
},
5050
{
51-
"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.
52-
For SVG icons, use the \`iconSvg\` slot instead.
53-
If you set both \`iconUrl\` and \`iconSvg\`, \`iconSvg\` will take precedence.
54-
",
51+
"deprecatedTag": "Use \`iconSvg\` or \`imgUrl\` instead.",
52+
"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.",
5553
"name": "iconUrl",
5654
"optional": true,
5755
"type": "string",
5856
},
57+
{
58+
"description": "Specifies the URL of a custom image. If you set both \`iconUrl\` and \`imgUrl\`, \`imgUrl\` will take precedence.",
59+
"name": "imgUrl",
60+
"optional": true,
61+
"type": "string",
62+
},
5963
{
6064
"description": "The text content shown directly in the avatar's body.
6165
Can be 1 or 2 symbols long, every subsequent symbol is ignored.
@@ -78,13 +82,19 @@ When you use this property, make sure to include it in the \`ariaLabel\`.
7882
"optional": true,
7983
"type": "string",
8084
},
85+
{
86+
"description": "Defines the width and height of the avatar.
87+
This value corresponds to the \`width\` CSS-property and will center and crop images using \`object-fit: cover\`.
88+
The default and minimum width value is 28px.",
89+
"name": "width",
90+
"optional": true,
91+
"type": "number",
92+
},
8193
],
8294
"regions": [
8395
{
8496
"description": "Specifies the SVG of a custom icon.
85-
Use this property if the icon you want isn't available.
86-
If you set both \`iconUrl\` and \`iconSvg\`, \`iconSvg\` will take precedence.
87-
",
97+
Use this property if the icon you want isn't available.",
8898
"isDefault": false,
8999
"name": "iconSvg",
90100
},

src/avatar/__tests__/avatar.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Avatar, { AvatarProps } from "../../../lib/components/avatar";
99
import createWrapper from "../../../lib/components/test-utils/dom";
1010

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

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

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

34+
test("Renders avatar with image", () => {
35+
const imgUrl =
36+
"https://static1.colliderimages.com/wordpress/wp-content/uploads/2024/08/deadpool-wolverine-hugh-jackman-mask-reveal.jpg";
37+
const wrapper = renderAvatar({ ...defaultAvatarProps, imgUrl });
38+
39+
const image = wrapper.findByClassName(avatarStyles.image)?.getElement();
40+
expect(image).toBeInTheDocument();
41+
});
42+
3343
test("Shows tooltip on focus", () => {
3444
const tooltipText = "Jane Doe";
3545
const wrapper = renderAvatar({ ...defaultAvatarProps, color: "default", tooltipText });
@@ -75,6 +85,15 @@ describe("Avatar", () => {
7585
expect(loading).toBeInTheDocument();
7686
});
7787

88+
test("Loading takes precedence over image", () => {
89+
const imgUrl =
90+
"https://static1.colliderimages.com/wordpress/wp-content/uploads/2024/08/deadpool-wolverine-hugh-jackman-mask-reveal.jpg";
91+
const wrapper = renderAvatar({ ...defaultAvatarProps, imgUrl, loading: true });
92+
93+
const loading = wrapper.findByClassName(loadingDotsStyles.root)?.getElement();
94+
expect(loading).toBeInTheDocument();
95+
});
96+
7897
test("Shows warning when initials length is longer than 2", () => {
7998
const warnOnce = vi.spyOn(ComponentToolkitInternal, "warnOnce");
8099

src/avatar/interfaces.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,27 @@ export interface AvatarProps {
4747

4848
/**
4949
* 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.
50-
* For SVG icons, use the `iconSvg` slot instead.
51-
*
52-
* If you set both `iconUrl` and `iconSvg`, `iconSvg` will take precedence.
50+
* @deprecated Use `iconSvg` or `imgUrl` instead.
5351
*/
5452
iconUrl?: string;
5553

5654
/**
5755
* Specifies the SVG of a custom icon.
58-
*
5956
* Use this property if the icon you want isn't available.
60-
* If you set both `iconUrl` and `iconSvg`, `iconSvg` will take precedence.
6157
*/
6258
iconSvg?: React.ReactNode;
59+
60+
/**
61+
* Specifies the URL of a custom image. If you set both `iconUrl` and `imgUrl`, `imgUrl` will take precedence.
62+
*/
63+
imgUrl?: string;
64+
65+
/**
66+
* Defines the width and height of the avatar.
67+
* This value corresponds to the `width` CSS-property and will center and crop images using `object-fit: cover`.
68+
* The default and minimum width value is 28px.
69+
*/
70+
width?: number;
6371
}
6472

6573
export namespace AvatarProps {

src/avatar/internal.tsx

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import clsx from "clsx";
66
import { warnOnce } from "@cloudscape-design/component-toolkit/internal";
77
import Icon from "@cloudscape-design/components/icon";
88
import Tooltip from "@cloudscape-design/components/internal/tooltip-do-not-use";
9+
import * as awsui from "@cloudscape-design/design-tokens";
910

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

1819
export interface InternalAvatarProps extends AvatarProps, InternalBaseComponentProps {}
1920

20-
const AvatarContent = ({ color, loading, initials, iconName, iconSvg, iconUrl, ariaLabel }: AvatarProps) => {
21+
const AvatarContent = ({
22+
color,
23+
loading,
24+
initials,
25+
iconName,
26+
iconSvg,
27+
iconUrl,
28+
ariaLabel,
29+
width,
30+
imgUrl,
31+
}: AvatarProps) => {
2132
if (loading) {
22-
return <LoadingDots color={color} />;
33+
return <LoadingDots color={color} width={width} />;
34+
}
35+
36+
if (imgUrl) {
37+
return <img className={styles.image} src={imgUrl} style={{ height: width, width: width }} />;
2338
}
2439

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

32-
return <span>{letters}</span>;
47+
return (
48+
<span style={{ fontSize: `clamp(${awsui.fontSizeBodyS}, calc(0.4px * ${width}), calc(0.4px * ${width}))` }}>
49+
{letters}
50+
</span>
51+
);
3352
}
3453

35-
return <Icon name={iconName} svg={iconSvg} url={iconUrl} alt={ariaLabel} />;
54+
return <Icon name={iconName} svg={iconSvg} url={iconUrl} alt={ariaLabel} size="inherit" />;
3655
};
3756

3857
export default function InternalAvatar({
@@ -44,13 +63,16 @@ export default function InternalAvatar({
4463
iconName,
4564
iconSvg,
4665
iconUrl,
66+
imgUrl,
67+
width = 28,
4768
__internalRootRef = null,
4869
...rest
4970
}: InternalAvatarProps) {
5071
const handleRef = useRef<HTMLDivElement>(null);
5172
const [showTooltip, setShowTooltip] = useState(false);
5273

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

5577
const tooltipAttributes = {
5678
onFocus: () => {
@@ -84,6 +106,7 @@ export default function InternalAvatar({
84106
role="img"
85107
aria-label={ariaLabel}
86108
{...tooltipAttributes}
109+
style={{ height: computedSize, width: computedSize }}
87110
>
88111
{showTooltip && tooltipText && (
89112
<Tooltip
@@ -96,7 +119,7 @@ export default function InternalAvatar({
96119

97120
{/* aria-hidden is added so that screen readers focus only the parent div */}
98121
{/* when it is not hidden, it becomes unstable in JAWS */}
99-
<div className={styles.content} aria-hidden="true">
122+
<div className={styles.content} aria-hidden="true" style={{ lineHeight: `calc(.8px * ${computedSize})` }}>
100123
<AvatarContent
101124
color={color}
102125
ariaLabel={ariaLabel}
@@ -105,6 +128,8 @@ export default function InternalAvatar({
105128
iconName={iconName}
106129
iconSvg={iconSvg}
107130
iconUrl={iconUrl}
131+
imgUrl={imgUrl}
132+
width={computedSize}
108133
/>
109134
</div>
110135
</div>

src/avatar/loading-dots/index.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,19 @@ import styles from "./styles.css.js";
66

77
interface LoadingDotsProps {
88
color?: string;
9+
width?: number;
910
}
1011

11-
export default function LoadingDots({ color }: LoadingDotsProps) {
12+
export default function LoadingDots({ color, width }: LoadingDotsProps) {
13+
const dotSize = `calc(.14px * ${width})`;
14+
1215
return (
1316
// "gen-ai" class is added so that the gradient background animates.
1417
<div className={clsx(styles.root, { [styles["gen-ai"]]: color === "gen-ai" })}>
1518
<div className={styles.typing}>
16-
<div className={styles.dot}></div>
17-
<div className={styles.dot}></div>
18-
<div className={styles.dot}></div>
19+
<div className={styles.dot} style={{ blockSize: dotSize, inlineSize: dotSize }}></div>
20+
<div className={styles.dot} style={{ blockSize: dotSize, inlineSize: dotSize }}></div>
21+
<div className={styles.dot} style={{ blockSize: dotSize, inlineSize: dotSize }}></div>
1922
</div>
2023
</div>
2124
);

0 commit comments

Comments
 (0)