diff --git a/pages/avatar/custom-width.page.tsx b/pages/avatar/custom-width.page.tsx new file mode 100644 index 0000000..b507651 --- /dev/null +++ b/pages/avatar/custom-width.page.tsx @@ -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 ( + +
Input an Image URL and custom size.
+ + + + setWidth(detail.value)} value={width} inputMode="numeric" type="number" /> + + + + setURL(detail.value)} value={url} /> + + + + setInitials(detail.value)} value={initials} /> + + + + setIconName(detail.value)} value={iconName} /> + + + setLoading(detail.checked)} checked={loading}> + Loading + + + setGenAI(detail.checked)} checked={genAI}> + Gen AI + + + + +
+ ); +} diff --git a/pages/avatar/permutations.page.tsx b/pages/avatar/permutations.page.tsx index bb4f0e4..6b8c5fb 100644 --- a/pages/avatar/permutations.page.tsx +++ b/pages/avatar/permutations.page.tsx @@ -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 = ( + + {/* Loading should take prioirty over image */} + +
+ + + {/* Image with tiny width enforce minimum of 28px */} + + + {/* Image with default width of 28px */} + + + {/* Image should take priority over initials */} + + + {/* Image and tooltip should take priority over icon */} + + + {/* Icon SVG with custom width */} + + + {/* Icon name with custom width */} + + + {/* Icon name with custom width */} + + + {/* Initials with custom width */} + + + {/* Loading with custom width */} + + + {/* Initials with custom width */} + + + {/* Loading with custom width */} + + diff --git a/pages/avatar/smiley.png b/pages/avatar/smiley.png new file mode 100644 index 0000000..206dfd2 Binary files /dev/null and b/pages/avatar/smiley.png differ diff --git a/src/__tests__/__snapshots__/documenter.test.ts.snap b/src/__tests__/__snapshots__/documenter.test.ts.snap index c855894..01efa59 100644 --- a/src/__tests__/__snapshots__/documenter.test.ts.snap +++ b/src/__tests__/__snapshots__/documenter.test.ts.snap @@ -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. @@ -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", }, diff --git a/src/avatar/__tests__/avatar.test.tsx b/src/avatar/__tests__/avatar.test.tsx index e865522..5f9ffc1 100644 --- a/src/avatar/__tests__/avatar.test.tsx +++ b/src/avatar/__tests__/avatar.test.tsx @@ -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" }; @@ -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 }); @@ -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"); diff --git a/src/avatar/interfaces.ts b/src/avatar/interfaces.ts index 0898b9d..d035e47 100644 --- a/src/avatar/interfaces.ts +++ b/src/avatar/interfaces.ts @@ -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 { diff --git a/src/avatar/internal.tsx b/src/avatar/internal.tsx index 7a5bb03..a15008d 100644 --- a/src/avatar/internal.tsx +++ b/src/avatar/internal.tsx @@ -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"; @@ -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 ; + return ; + } + + if (imgUrl) { + return ; } if (initials) { @@ -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 {letters}; + return ( + + {letters} + + ); } - return ; + return ; }; export default function InternalAvatar({ @@ -44,6 +63,8 @@ export default function InternalAvatar({ iconName, iconSvg, iconUrl, + imgUrl, + width = 28, __internalRootRef = null, ...rest }: InternalAvatarProps) { @@ -51,6 +72,7 @@ export default function InternalAvatar({ const [showTooltip, setShowTooltip] = useState(false); const mergedRef = useMergeRefs(handleRef, __internalRootRef); + const computedSize = width < 28 ? 28 : width; const tooltipAttributes = { onFocus: () => { @@ -84,6 +106,7 @@ export default function InternalAvatar({ role="img" aria-label={ariaLabel} {...tooltipAttributes} + style={{ height: computedSize, width: computedSize }} > {showTooltip && tooltipText && ( + diff --git a/src/avatar/loading-dots/index.tsx b/src/avatar/loading-dots/index.tsx index e112e51..9095945 100644 --- a/src/avatar/loading-dots/index.tsx +++ b/src/avatar/loading-dots/index.tsx @@ -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.
-
-
-
+
+
+
); diff --git a/src/avatar/loading-dots/motion.scss b/src/avatar/loading-dots/motion.scss index 72c2e32..f7ef051 100644 --- a/src/avatar/loading-dots/motion.scss +++ b/src/avatar/loading-dots/motion.scss @@ -39,8 +39,8 @@ } 50% { - block-size: 44px; - inline-size: 44px; + block-size: 150%; + inline-size: 150%; } 100% { @@ -55,8 +55,8 @@ } 50% { - block-size: 44px; - inline-size: 44px; + block-size: 150%; + inline-size: 150%; inset-inline-start: -100%; } @@ -103,7 +103,7 @@ } 28% { - transform: translateY(-4px); + transform: translateY(-100%); } 44% { diff --git a/src/avatar/loading-dots/styles.scss b/src/avatar/loading-dots/styles.scss index c87fd23..5cea750 100644 --- a/src/avatar/loading-dots/styles.scss +++ b/src/avatar/loading-dots/styles.scss @@ -23,7 +23,7 @@ $dot-size: 4px; align-items: center; justify-content: space-between; display: flex; - inline-size: 18px; + inline-size: 64%; } .dot { diff --git a/src/avatar/styles.scss b/src/avatar/styles.scss index 7ec4f0d..134055d 100644 --- a/src/avatar/styles.scss +++ b/src/avatar/styles.scss @@ -10,7 +10,6 @@ $avatar-size: 28px; .root { @include shared.styles-reset; - color: cs.$color-text-avatar; block-size: $avatar-size; inline-size: $avatar-size; @@ -39,6 +38,10 @@ $avatar-size: 28px; @include shared.focus-highlight(1px, 50%); } } + + &:has(.image) { + background: transparent; + } } .content { @@ -48,4 +51,11 @@ $avatar-size: 28px; block-size: inherit; inline-size: inherit; overflow: hidden; + + .image { + @include mixins.border-radius-avatar; + block-size: $avatar-size; + inline-size: $avatar-size; + object-fit: cover; + } }