diff --git a/apps/website/pages/components/avatar/code.tsx b/apps/website/pages/components/avatar/code.tsx new file mode 100644 index 000000000..7bee7fbb8 --- /dev/null +++ b/apps/website/pages/components/avatar/code.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import { ReactElement } from "react-markdown/lib/react-markdown"; +import AvatarPageLayout from "screens/components/avatar/AvatarPageLayout"; +import AvatarCodePage from "screens/components/avatar/code/AvatarCodePage"; + +const Code = () => ( + <> + + Avatar code - Halstack Design + + + +); + +Code.getLayout = (page: ReactElement) => {page}; + +export default Code; diff --git a/apps/website/pages/components/avatar/index.tsx b/apps/website/pages/components/avatar/index.tsx new file mode 100644 index 000000000..4de8cad70 --- /dev/null +++ b/apps/website/pages/components/avatar/index.tsx @@ -0,0 +1,17 @@ +import Head from "next/head"; +import { ReactElement } from "react-markdown/lib/react-markdown"; +import AvatarPageLayout from "screens/components/avatar/AvatarPageLayout"; +import AvatarOverviewPage from "screens/components/avatar/overview/AvatarOverviewPage"; + +const Index = () => ( + <> + + Avatar - Halstack Design System + + + +); + +Index.getLayout = (page: ReactElement) => {page}; + +export default Index; diff --git a/apps/website/screens/common/componentsList.json b/apps/website/screens/common/componentsList.json index 6037d4c84..48a94562c 100644 --- a/apps/website/screens/common/componentsList.json +++ b/apps/website/screens/common/componentsList.json @@ -6,6 +6,11 @@ "path": "/components/application-layout", "status": "stable" }, + { + "label": "Avatar", + "path": "/components/avatar", + "status": "experimental" + }, { "label": "Badge", "path": "/components/badge", diff --git a/apps/website/screens/components/avatar/AvatarPageLayout.tsx b/apps/website/screens/components/avatar/AvatarPageLayout.tsx new file mode 100644 index 000000000..d3d4ce42a --- /dev/null +++ b/apps/website/screens/components/avatar/AvatarPageLayout.tsx @@ -0,0 +1,31 @@ +import ComponentHeading from "@/common/ComponentHeading"; +import PageHeading from "@/common/PageHeading"; +import TabsPageHeading from "@/common/TabsPageLayout"; +import { DxcFlex, DxcParagraph } from "@dxc-technology/halstack-react"; +import { ReactNode } from "react"; + +const AvatarPageHeading = ({ children }: { children: ReactNode }) => { + const tabs = [ + { label: "Overview", path: "/components/avatar" }, + { label: "Code", path: "/components/avatar/code" }, + ]; + + return ( + + + + + + The Avatar component is a key visual element used to identify users, teams, or entities across the + interface. It helps create a recognizable and consistent user experience by visually representing people or + objects through images, icons, or initials. + + + + + {children} + + ); +}; + +export default AvatarPageHeading; diff --git a/apps/website/screens/components/avatar/code/AvatarCodePage.tsx b/apps/website/screens/components/avatar/code/AvatarCodePage.tsx new file mode 100644 index 000000000..f02fd4e3b --- /dev/null +++ b/apps/website/screens/components/avatar/code/AvatarCodePage.tsx @@ -0,0 +1,178 @@ +import { ExtendedTableCode, TableCode } from "@/common/Code"; +import Example from "@/common/example/Example"; +import DxcQuickNavContainer from "@/common/QuickNavContainer"; +import { DxcFlex, DxcTable } from "@dxc-technology/halstack-react"; +import DocFooter from "@/common/DocFooter"; +import basicUsage from "./examples/basicUsage"; +import clickable from "./examples/clickable"; +import tooltip from "./examples/tooltip"; +import status from "./examples/status"; + +const statusTypeString = `{ + mode: 'default' | 'info' | + 'success' | 'warning' | 'error'; + position: 'top' | 'bottom'; +}`; + +const sections = [ + { + title: "Props", + content: ( + + + + Name + Type + Description + Default + + + + + color + + + 'primary' | 'secondary' | 'tertiary' | 'success' | 'info' | 'neutral' |'warning' | 'error' + + + Affects the visual style of the avatar. It can be used following semantic purposes or not. + + 'neutral' + + + + disabled + + boolean + + If true, the componente will be disabled. + + false + + + + icon + + string | SVG + + Material Symbol name or SVG element as the icon that will be placed as avatar. + + 'person' + + + + imageSrc + + string + + URL of the image. + - + + + label + + string + + Text label associated with the avatar. Used to generate and display initials inside the avatar. + - + + + linkHref + + string + + Page to be opened when the user clicks on the link. + - + + + onClick + + {"() => void"} + + This function will be called when the user clicks the avatar. Makes it behave as a button. + - + + + shape + + 'circle' | 'square' + + This will determine if the avatar will be a rounded square or a circle. + + 'circle' + + + + size + + 'xsmall' | 'small' | 'medium' | 'large' | 'xlarge' | 'xxlarge' + + Size of the component. + + 'medium' + + + + status + + {statusTypeString} + + + Defines the color of the status indicator displayed on the avatar and where it will be placed. If not + provided, no indicator will be rendered. + + - + + + tabIndex + + number + + Value of the tabindex attribute. It will only apply when the onClick property is passed. + + 0 + + + + title + + string + + Text to be displayed inside a tooltip when hovering the avatar. + - + + + + ), + }, + { + title: "Examples", + subSections: [ + { + title: "Basic usage", + content: , + }, + { + title: "Clickable", + content: , + }, + { + title: "Status", + content: , + }, + { + title: "Tooltip", + content: , + }, + ], + }, +]; + +const AvatarCodePage = () => ( + + + + +); + +export default AvatarCodePage; diff --git a/apps/website/screens/components/avatar/code/examples/basicUsage.tsx b/apps/website/screens/components/avatar/code/examples/basicUsage.tsx new file mode 100644 index 000000000..c38a26051 --- /dev/null +++ b/apps/website/screens/components/avatar/code/examples/basicUsage.tsx @@ -0,0 +1,19 @@ +import { DxcAvatar, DxcInset } from "@dxc-technology/halstack-react"; + +const code = `() => { + return ( + + + + ); +}`; + +const scope = { + DxcAvatar, + DxcInset, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/avatar/code/examples/clickable.tsx b/apps/website/screens/components/avatar/code/examples/clickable.tsx new file mode 100644 index 000000000..a72c07b00 --- /dev/null +++ b/apps/website/screens/components/avatar/code/examples/clickable.tsx @@ -0,0 +1,19 @@ +import { DxcAvatar, DxcInset } from "@dxc-technology/halstack-react"; + +const code = `() => { + return ( + + console.log("Hello")} + /> + + ); +}`; + +const scope = { + DxcAvatar, + DxcInset, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/avatar/code/examples/status.tsx b/apps/website/screens/components/avatar/code/examples/status.tsx new file mode 100644 index 000000000..9fd0aaae7 --- /dev/null +++ b/apps/website/screens/components/avatar/code/examples/status.tsx @@ -0,0 +1,19 @@ +import { DxcAvatar, DxcInset } from "@dxc-technology/halstack-react"; + +const code = `() => { + return ( + + + + ); +}`; + +const scope = { + DxcAvatar, + DxcInset, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/avatar/code/examples/tooltip.tsx b/apps/website/screens/components/avatar/code/examples/tooltip.tsx new file mode 100644 index 000000000..aea884a5f --- /dev/null +++ b/apps/website/screens/components/avatar/code/examples/tooltip.tsx @@ -0,0 +1,19 @@ +import { DxcAvatar, DxcInset } from "@dxc-technology/halstack-react"; + +const code = `() => { + return ( + + + + ); +}`; + +const scope = { + DxcAvatar, + DxcInset, +}; + +export default { code, scope }; diff --git a/apps/website/screens/components/avatar/overview/AvatarOverviewPage.tsx b/apps/website/screens/components/avatar/overview/AvatarOverviewPage.tsx new file mode 100644 index 000000000..c3c4b937a --- /dev/null +++ b/apps/website/screens/components/avatar/overview/AvatarOverviewPage.tsx @@ -0,0 +1,230 @@ +import DocFooter from "@/common/DocFooter"; +import Image from "@/common/Image"; +import DxcQuickNavContainer from "@/common/QuickNavContainer"; +import { DxcBulletedList, DxcFlex, DxcParagraph, DxcTable } from "@dxc-technology/halstack-react"; +import anatomy from "./images/avatar_anatomy.png"; +import shape from "./images/avatar_shape.png"; +import contentTypes from "./images/avatar_content_types.png"; +import colors from "./images/avatar_colors.png"; +import sizes from "./images/avatar_sizes.png"; + +const sections = [ + { + title: "Overview", + content: ( + <> + + The Avatar component represents users or entities using visual identifiers such as icons, initials, or images. + + + It ensures consistency across the interface and supports various states, color variants, and contextual + information like user roles or availability. + + + Avatars are typically used in headers, navigation bars, profile cards, chat interfaces, and user lists. + + + ), + }, + { + title: "Anatomy", + content: ( + <> + Avatar anatomy + + + Base shape: defines the visual form of the avatar. + + + Content area: displays the main visual content. + + + Status indicator (Optional): a small color light that communicates user presence or status. + + + Label & sublabel (Optional): textual information placed next to or below the avatar, + providing context such as name, role, or email. + + + + ), + }, + { + title: "Variants", + content: ( + <> + + The Avatar component is designed to be highly versatile, adapting to a wide range of use cases and interface + needs. + + + Through its different shapes, content types, sizes, and color options, it can seamlessly + represent users, teams, or entities across various contexts, from compact tables to rich profile sections. + + + Each variant ensures visual consistency while providing the flexibility to match the tone and hierarchy of the + experience. + + + ), + }, + { + title: "Shape", + content: ( + <> + Avatar shape + + + Round: the default option, best for personal profiles and chat contexts. + + + Square: ideal for products, organizations, or abstract entities. + + + + ), + }, + { + title: "Content types", + content: ( + <> + Avatar content types + + + Default icon: generic placeholder when no data is available. + + + Custom icon: allows brand-specific or role-specific icons. + + + Initials: displays user initials. + + + Image: uses a user or entity photo. + + + + If an image or custom icon fails to load, a fallback (initials or default icon) is automatically displayed. + + + ), + }, + { + title: "Colors", + content: ( + <> + Avatar colors + + By default, the first avatar uses the primary brand color as its background. However, the + component supports multiple color variants, which is especially useful when{" "} + differentiating between several avatars displayed together on screen, such as in team lists, + conversation threads, or collaborative views. + + + ), + }, + { + title: "Sizes", + content: ( + <> + + The Avatar component is available in six size variants, each designed to fit specific interface contexts, from + compact data tables to prominent profile headers. Choosing the right size ensures that avatars maintain visual + balance and hierarchy across different layouts and use cases. + + Avatar sizes + + + + Variant + Size (px) + Typical usage + + + + + + xsmall + + 24px + Tables, dense lists. + + + + small + + 32px + Headers, compact cards. + + + + medium + + 40px + Sidenav bars, user previews, chat threads. + + + + large + + 56px + Medium cards, profile sections... + + + + xlarge + + 72px + Modals, profile headers, featured content. + + + + xxlarge + + 80px + Large cards or highlight sections. + + + + + ), + }, + { + title: "Best practices", + content: ( + + + Use avatars to support recognition, not decoration: Place avatars where they help users + identify people, entities, or actions (such as in chat lists, comments, or team overviews) rather than as + purely decorative elements. + + + Keep visual hierarchy clear: Use avatar sizes consistently according to layout importance + (e.g., small in lists, large in profile headers). Avoid mixing different sizes in the same view unless + contextually justified. + + + Maintain alignment and spacing: Ensure consistent padding and alignment between avatars, + labels, and sublabels to preserve visual rhythm and readability. + + + Show status indicators only when relevant: Use status lights to communicate meaningful + information (e.g., online/offline) and avoid visual clutter in contexts where status is not needed. + + + Use color purposefully: Choose avatar background colors that align with brand or semantic + meaning, and avoid overusing color variants within a single view. + + + ), + }, +]; + +const AvatarOverviewPage = () => ( + + + + +); + +export default AvatarOverviewPage; diff --git a/apps/website/screens/components/avatar/overview/images/avatar_anatomy.png b/apps/website/screens/components/avatar/overview/images/avatar_anatomy.png new file mode 100644 index 000000000..52836fea0 Binary files /dev/null and b/apps/website/screens/components/avatar/overview/images/avatar_anatomy.png differ diff --git a/apps/website/screens/components/avatar/overview/images/avatar_colors.png b/apps/website/screens/components/avatar/overview/images/avatar_colors.png new file mode 100644 index 000000000..d6d00bc81 Binary files /dev/null and b/apps/website/screens/components/avatar/overview/images/avatar_colors.png differ diff --git a/apps/website/screens/components/avatar/overview/images/avatar_content_types.png b/apps/website/screens/components/avatar/overview/images/avatar_content_types.png new file mode 100644 index 000000000..66a8a09db Binary files /dev/null and b/apps/website/screens/components/avatar/overview/images/avatar_content_types.png differ diff --git a/apps/website/screens/components/avatar/overview/images/avatar_shape.png b/apps/website/screens/components/avatar/overview/images/avatar_shape.png new file mode 100644 index 000000000..6442ef3eb Binary files /dev/null and b/apps/website/screens/components/avatar/overview/images/avatar_shape.png differ diff --git a/apps/website/screens/components/avatar/overview/images/avatar_sizes.png b/apps/website/screens/components/avatar/overview/images/avatar_sizes.png new file mode 100644 index 000000000..f9fa80556 Binary files /dev/null and b/apps/website/screens/components/avatar/overview/images/avatar_sizes.png differ diff --git a/packages/lib/.storybook/components/ExampleContainer.tsx b/packages/lib/.storybook/components/ExampleContainer.tsx index 296bc04eb..f27c7155a 100644 --- a/packages/lib/.storybook/components/ExampleContainer.tsx +++ b/packages/lib/.storybook/components/ExampleContainer.tsx @@ -1,7 +1,7 @@ import { ReactNode } from "react"; import styled from "@emotion/styled"; -type PseudoStates = +export type PseudoState = | "pseudo-active" | "pseudo-focus" | "pseudo-focus-visible" @@ -13,7 +13,7 @@ type PseudoStates = type Props = { children?: ReactNode; - pseudoState?: PseudoStates | PseudoStates[]; + pseudoState?: PseudoState | PseudoState[]; expanded?: boolean; }; diff --git a/packages/lib/src/avatar/Avatar.accessibility.test.tsx b/packages/lib/src/avatar/Avatar.accessibility.test.tsx new file mode 100644 index 000000000..27c161f6e --- /dev/null +++ b/packages/lib/src/avatar/Avatar.accessibility.test.tsx @@ -0,0 +1,36 @@ +import { render } from "@testing-library/react"; +import { axe } from "../../test/accessibility/axe-helper"; +import DxcAvatar from "./Avatar"; + +describe("Avatar component accessibility tests", () => { + it("Should not have basic accessibility issues", async () => { + const { container } = render(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + it("Should not have basic accessibility issues when it works as a button", async () => { + const { container } = render( console.log("")} />); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + it("Should not have basic accessibility issues when it works as an anchor", async () => { + const { container } = render(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + it("Should not have basic accessibility issues when disabled", async () => { + const { container } = render(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + it("Should not have basic accessibility issues when status is passed", async () => { + const { container } = render(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); + it("Should not have basic accessibility issues when image is passed", async () => { + const { container } = render(); + const results = await axe(container); + expect(results.violations).toHaveLength(0); + }); +}); diff --git a/packages/lib/src/avatar/Avatar.stories.tsx b/packages/lib/src/avatar/Avatar.stories.tsx new file mode 100644 index 000000000..232a10a4a --- /dev/null +++ b/packages/lib/src/avatar/Avatar.stories.tsx @@ -0,0 +1,233 @@ +import { Meta, StoryObj } from "@storybook/react-vite"; +import DxcAvatar from "./Avatar"; +import DxcFlex from "../flex/Flex"; +import Title from "../../.storybook/components/Title"; +import ExampleContainer, { PseudoState } from "../../.storybook/components/ExampleContainer"; +import AvatarPropsType, { Status } from "./types"; + +export default { + title: "Avatar", + component: DxcAvatar, +} satisfies Meta; + +type Story = StoryObj; + +type GroupingKey = "size" | "shape" | "color" | "statusPosition" | "statusMode" | "pseudoState"; + +type AvatarRowProps = { + sizes?: AvatarPropsType["size"][]; + shapes?: AvatarPropsType["shape"][]; + colors?: AvatarPropsType["color"][]; + label?: AvatarPropsType["label"]; + icon?: AvatarPropsType["icon"]; + imageSrc?: AvatarPropsType["imageSrc"]; + statusModes?: Status["mode"][]; + statusPositions?: (Status["position"] | undefined)[]; + pseudoStates?: (PseudoState | undefined)[]; + groupBy?: GroupingKey[]; +}; + +const AvatarRow = ({ + sizes = ["medium"], + shapes = ["circle"], + colors = ["neutral"], + label, + icon, + imageSrc, + statusModes, + statusPositions = [], + pseudoStates = [], + groupBy = ["size"], +}: AvatarRowProps) => { + const getValuesForKey = (key?: GroupingKey) => { + switch (key) { + case "size": + return sizes as string[]; + case "shape": + return shapes as string[]; + case "color": + return colors as string[]; + case "statusPosition": + return statusPositions as string[]; + case "statusMode": + return statusModes as string[]; + case "pseudoState": + return pseudoStates; + default: + return []; + } + }; + + const renderGroup = ( + level: number, + filters: { + size?: AvatarPropsType["size"]; + shape?: AvatarPropsType["shape"]; + color?: AvatarPropsType["color"]; + statusMode?: Status["mode"]; + statusPosition?: Status["position"]; + pseudoState?: PseudoState; + } + ): JSX.Element | JSX.Element[] => { + if (level >= groupBy.length) { + const sizesToRender = filters.size ? [filters.size] : sizes; + const colorsToRender = filters.color ? [filters.color] : colors; + const shapesToRender = filters.shape ? [filters.shape] : shapes; + const positionsToRender = filters.statusPosition + ? [filters.statusPosition] + : statusPositions.length + ? statusPositions + : [undefined]; + const modesToRender = filters.statusMode ? [filters.statusMode] : statusModes?.length ? statusModes : [undefined]; + + const pseudoStatesEnabled = !!filters.pseudoState; + + return shapesToRender.map((shape) => ( + + {sizesToRender.map((size) => + colorsToRender.map((color) => + positionsToRender.map((position) => + modesToRender.map((mode) => ( + + console.log("") : undefined} + /> + + )) + ) + ) + )} + + )); + } + + const key = groupBy[level]; + const values = getValuesForKey(key); + + return values.map((value) => { + const newFilters = { ...filters }; + if (key === "size") newFilters.size = value as AvatarPropsType["size"]; + else if (key === "shape") newFilters.shape = value as AvatarPropsType["shape"]; + else if (key === "color") newFilters.color = value as AvatarPropsType["color"]; + else if (key === "statusPosition") newFilters.statusPosition = value as Status["position"]; + else if (key === "statusMode") newFilters.statusMode = value as Status["mode"]; + else if (key === "pseudoState") newFilters.pseudoState = value as PseudoState; + + return ( +
+ + {renderGroup(level + 1, newFilters)} + </div> + ); + }); + }; + + return <>{renderGroup(0, {})}</>; +}; + +export const Shapes: Story = { + render: () => ( + <> + <Title title="Shapes" theme="light" level={2} /> + <AvatarRow + sizes={["xsmall", "small", "medium", "large", "xlarge", "xxlarge"]} + shapes={["circle", "square"]} + groupBy={["shape", "size"]} + /> + </> + ), +}; + +export const Colors: Story = { + render: () => ( + <> + <Title title="Colors" theme="light" level={2} /> + <AvatarRow + sizes={["medium"]} + shapes={["circle"]} + colors={["neutral", "primary", "secondary", "tertiary", "success", "warning", "error", "info"]} + groupBy={["color"]} + /> + </> + ), +}; + +export const Statuses: Story = { + render: () => ( + <> + <Title title="Statuses" theme="light" level={2} /> + <AvatarRow + sizes={["xsmall", "small", "medium", "large", "xlarge", "xxlarge"]} + colors={["neutral", "primary", "secondary", "tertiary", "success", "warning", "error", "info"]} + shapes={["circle"]} + statusModes={["default", "info", "success", "warning", "error"]} + statusPositions={["top", "bottom"]} + groupBy={["statusPosition", "statusMode", "color"]} + /> + </> + ), +}; + +export const PseudoStates: Story = { + render: () => ( + <> + <Title title="Pseudo states" theme="light" level={2} /> + <AvatarRow + sizes={["xsmall", "small", "medium", "large", "xlarge", "xxlarge"]} + shapes={["circle"]} + statusModes={["success"]} + statusPositions={[undefined, "top", "bottom"]} + pseudoStates={[undefined, "pseudo-hover", "pseudo-focus", "pseudo-active"]} + groupBy={["pseudoState", "size"]} + /> + </> + ), +}; + +export const Types: Story = { + render: () => ( + <> + <Title title="Label" theme="light" level={2} /> + <AvatarRow + sizes={["xsmall", "small", "medium", "large", "xlarge", "xxlarge"]} + shapes={["circle"]} + label="John Doe" + groupBy={["size"]} + /> + <Title title="Image" theme="light" level={2} /> + <AvatarRow + sizes={["xsmall", "small", "medium", "large", "xlarge", "xxlarge"]} + shapes={["circle"]} + imageSrc="https://picsum.photos/id/1022/200/300" + groupBy={["size"]} + /> + <Title title="Icon (custom)" theme="light" level={2} /> + <AvatarRow + sizes={["xsmall", "small", "medium", "large", "xlarge", "xxlarge"]} + shapes={["circle"]} + icon="settings" + groupBy={["size"]} + /> + <Title title="Icon (default)" theme="light" level={2} /> + <AvatarRow + sizes={["xsmall", "small", "medium", "large", "xlarge", "xxlarge"]} + shapes={["circle"]} + groupBy={["size"]} + /> + </> + ), +}; diff --git a/packages/lib/src/avatar/Avatar.test.tsx b/packages/lib/src/avatar/Avatar.test.tsx new file mode 100644 index 000000000..4cc258dd9 --- /dev/null +++ b/packages/lib/src/avatar/Avatar.test.tsx @@ -0,0 +1,121 @@ +import "@testing-library/jest-dom"; +import { fireEvent, render } from "@testing-library/react"; +import DxcAvatar from "./Avatar"; + +describe("Avatar component tests", () => { + test("Avatar renders correctly", () => { + const { getByRole } = render(<DxcAvatar />); + const avatar = getByRole("img", { hidden: true }); + expect(avatar).toBeInTheDocument(); + }); + test("Avatar renders with custom icon when icon is a SVG", () => { + const CustomIcon = () => <svg data-testid="custom-icon" />; + const { getByTestId } = render(<DxcAvatar icon={<CustomIcon />} />); + const icon = getByTestId("custom-icon"); + expect(icon).toBeInTheDocument(); + }); + test("Avatar renders with image when src is passed", () => { + const { getByRole } = render( + <DxcAvatar imageSrc="https://developer.dxc.com/halstack/next/_next/static/media/neutral_colors.e92a8be2.png" /> + ); + const img = getByRole("img"); + expect(img).toHaveAttribute( + "src", + "https://developer.dxc.com/halstack/next/_next/static/media/neutral_colors.e92a8be2.png" + ); + }); + test("Avatar renders with initials when label is passed", () => { + const { getByText } = render(<DxcAvatar label="John Doe" />); + const initials = getByText("JD"); + expect(initials).toBeInTheDocument(); + }); + test("Avatar renders with initials when src is invalid and label is passed", () => { + const { getByRole, getByText, queryByText } = render(<DxcAvatar imageSrc="invalid-url" label="John Doe" />); + const img = getByRole("img"); + expect(img).toBeInTheDocument(); + expect(queryByText("JD")).not.toBeInTheDocument(); + fireEvent.error(img); + const initials = getByText("JD"); + expect(initials).toBeInTheDocument(); + expect(img).not.toBeInTheDocument(); + }); + test("Avatar renders with image when src and label are passed", () => { + const { getByRole, queryByText } = render( + <DxcAvatar + imageSrc="https://developer.dxc.com/halstack/next/_next/static/media/neutral_colors.e92a8be2.png" + label="John Doe" + /> + ); + const img = getByRole("img"); + expect(img).toBeInTheDocument(); + const initials = queryByText("JD"); + expect(initials).not.toBeInTheDocument(); + }); + test("Avatar content fallback renders correctly in all cases", () => { + const CustomIcon = () => <svg data-testid="custom-icon" />; + const { rerender, getByRole, getByText, getByTestId, queryByRole, queryByText, queryByTestId } = render( + <DxcAvatar + imageSrc="https://developer.dxc.com/halstack/next/_next/static/media/neutral_colors.e92a8be2.png" + label="John Doe" + icon={<CustomIcon />} + /> + ); + expect(getByRole("img")).toBeInTheDocument(); + expect(queryByText("JD")).not.toBeInTheDocument(); + expect(queryByTestId("custom-icon")).not.toBeInTheDocument(); + expect(queryByRole("img", { hidden: true, name: "" })).not.toBeInTheDocument(); + rerender(<DxcAvatar label="John Doe" icon={<CustomIcon />} />); + expect(queryByRole("img")).not.toBeInTheDocument(); + expect(getByText("JD")).toBeInTheDocument(); + expect(queryByTestId("custom-icon")).not.toBeInTheDocument(); + expect(queryByRole("img", { hidden: true, name: "" })).not.toBeInTheDocument(); + rerender(<DxcAvatar icon={<CustomIcon />} />); + expect(queryByRole("img")).not.toBeInTheDocument(); + expect(queryByText("JD")).not.toBeInTheDocument(); + expect(getByTestId("custom-icon")).toBeInTheDocument(); + expect(queryByRole("img", { hidden: true, name: "" })).not.toBeInTheDocument(); + rerender(<DxcAvatar />); + expect(queryByRole("img")).not.toBeInTheDocument(); + expect(queryByText("JD")).not.toBeInTheDocument(); + expect(queryByTestId("custom-icon")).not.toBeInTheDocument(); + expect(getByRole("img", { hidden: true, name: "" })).toBeInTheDocument(); + }); + test("Avatar renders as a link when linkHref is passed", () => { + const { getByRole } = render(<DxcAvatar linkHref="/components/avatar" />); + const link = getByRole("link"); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute("href", "/components/avatar"); + }); + test("Avatar calls onClick when onClick is passed and component is clicked", () => { + const handleClick = jest.fn(); + const { getByRole } = render(<DxcAvatar onClick={handleClick} />); + const buttonDiv = getByRole("button"); + expect(buttonDiv).toBeInTheDocument(); + fireEvent.click(buttonDiv); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + test("Avatar renders status indicator correctly", () => { + const { rerender, queryByRole, getByRole } = render( + <DxcAvatar label="John Doe" status={{ mode: "default", position: "top" }} /> + ); + expect(getByRole("status")).toHaveStyle("background-color: var(--color-fg-neutral-strong)"); + rerender(<DxcAvatar label="John Doe" status={{ mode: "info", position: "top" }} />); + expect(getByRole("status")).toHaveStyle("background-color: var(--color-fg-secondary-medium)"); + rerender(<DxcAvatar label="John Doe" status={{ mode: "success", position: "top" }} />); + expect(getByRole("status")).toHaveStyle("background-color: var(--color-fg-success-medium)"); + rerender(<DxcAvatar label="John Doe" status={{ mode: "warning", position: "top" }} />); + expect(getByRole("status")).toHaveStyle("background-color: var(--color-fg-warning-strong)"); + rerender(<DxcAvatar label="John Doe" status={{ mode: "error", position: "top" }} />); + expect(getByRole("status")).toHaveStyle("background-color: var(--color-fg-error-medium)"); + rerender(<DxcAvatar label="John Doe" />); + expect(queryByRole("status")).toBeNull(); + }); + test("Avatar renders status indicator in correct position", () => { + const { rerender, getByRole } = render( + <DxcAvatar label="John Doe" status={{ mode: "default", position: "top" }} /> + ); + expect(getByRole("status")).toHaveStyle("top: 0px;"); + rerender(<DxcAvatar label="John Doe" status={{ mode: "info", position: "bottom" }} />); + expect(getByRole("status")).toHaveStyle("bottom: 0px"); + }); +}); diff --git a/packages/lib/src/avatar/Avatar.tsx b/packages/lib/src/avatar/Avatar.tsx new file mode 100644 index 000000000..a4e1a13bc --- /dev/null +++ b/packages/lib/src/avatar/Avatar.tsx @@ -0,0 +1,188 @@ +import { memo, useCallback, useMemo, useState } from "react"; +import styled from "@emotion/styled"; +import { css } from "@emotion/react"; +import AvatarPropsType from "./types"; +import { + getBackgroundColor, + getBorderRadius, + getBorderWidth, + getColor, + getFontSize, + getIconSize, + getInitials, + getModeColor, + getOutlineWidth, + getSize, +} from "./utils"; +import DxcTypography from "../typography/Typography"; +import DxcImage from "../image/Image"; +import DxcIcon from "../icon/Icon"; +import { TooltipWrapper } from "../tooltip/Tooltip"; + +const AvatarContainer = styled.div< + { + hasAction?: boolean; + size: AvatarPropsType["size"]; + disabled?: AvatarPropsType["disabled"]; + } & React.AnchorHTMLAttributes<HTMLAnchorElement> +>` + position: relative; + display: flex; + justify-content: center; + align-items: center; + height: ${({ size }) => getSize(size)}; + aspect-ratio: 1 / 1; + text-decoration: none; + ${({ hasAction, disabled, size }) => + !disabled && + hasAction && + css` + cursor: pointer; + &:hover > div:first-child > div:first-child, + &:active > div:first-child > div:first-child { + display: block; + } + &:focus > div:first-child, + &:active > div:first-child { + outline-style: solid; + outline-width: ${getOutlineWidth(size)}; + outline-color: var(--border-color-secondary-medium); + } + `} + ${({ disabled }) => + disabled && + css` + cursor: not-allowed; + & > div:first-child > div:first-child { + display: block; + background-color: rgba(255, 255, 255, 0.5); + } + `} +`; + +const AvatarWrapper = styled.div<{ + shape: AvatarPropsType["shape"]; + color: AvatarPropsType["color"]; + size: AvatarPropsType["size"]; +}>` + position: relative; + height: 100%; + aspect-ratio: 1 / 1; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + background-color: ${({ color }) => getBackgroundColor(color)}; + color: ${({ color }) => getColor(color)}; + border-radius: ${({ shape, size }) => getBorderRadius(shape, size)}; +`; + +const Overlay = styled.div` + display: none; + position: absolute; + inset: 0; + height: 100%; + width: 100%; + background-color: var(--color-alpha-400-a); +`; + +const AvatarIcon = styled.div<{ size: AvatarPropsType["size"] }>` + display: flex; + justify-content: center; + align-items: center; + line-height: 1; + font-size: ${({ size }) => getIconSize(size)}; +`; + +const StatusContainer = styled.div<{ + status: AvatarPropsType["status"]; + size: AvatarPropsType["size"]; +}>` + position: absolute; + right: 0px; + ${({ status }) => (status?.position === "top" ? "top: 0px;" : "bottom: 0px;")} + width: 25%; + height: 25%; + border-width: ${({ size }) => getBorderWidth(size)}; + border-style: solid; + border-color: var(--border-color-neutral-brighter); + border-radius: 100%; + background-color: ${({ status }) => getModeColor(status!.mode)}; +`; + +const DxcAvatar = memo( + ({ + color = "neutral", + disabled = false, + icon = "person", + imageSrc, + label, + linkHref, + onClick, + shape = "circle", + size = "medium", + status, + tabIndex = 0, + title, + }: AvatarPropsType) => { + const [error, setError] = useState<boolean>(false); + const initials = useMemo(() => getInitials(label), [label]); + const handleError = useCallback(() => setError(true), []); + + const content = ( + <> + {imageSrc && !error ? ( + <DxcImage + src={imageSrc} + alt={label || title || "Avatar"} + onError={handleError} + width="100%" + height="100%" + objectFit="cover" + objectPosition="center" + /> + ) : initials.length > 0 ? ( + <DxcTypography + as="span" + fontFamily="var(--typography-font-family)" + fontSize={getFontSize(size)} + fontWeight="var(--typography-label-semibold)" + fontStyle="normal" + lineHeight="normal" + color="inherit" + > + {initials} + </DxcTypography> + ) : ( + <AvatarIcon size={size} color={color}> + {icon && (typeof icon === "string" ? <DxcIcon icon={icon} /> : icon)} + </AvatarIcon> + )} + </> + ); + + return ( + <TooltipWrapper condition={!!title} label={title}> + <AvatarContainer + size={size} + onClick={!disabled ? onClick : undefined} + hasAction={!!onClick || !!linkHref} + tabIndex={!disabled && (onClick || linkHref) ? tabIndex : undefined} + role={onClick ? "button" : undefined} + as={linkHref ? "a" : undefined} + href={!disabled ? linkHref : undefined} + aria-label={(onClick || linkHref) && (label || title || "Avatar")} + disabled={disabled} + > + <AvatarWrapper shape={shape} color={color} size={size}> + <Overlay aria-hidden="true" /> + {content} + </AvatarWrapper> + {status && <StatusContainer role="status" size={size} status={status} />} + </AvatarContainer> + </TooltipWrapper> + ); + } +); + +export default DxcAvatar; diff --git a/packages/lib/src/avatar/types.ts b/packages/lib/src/avatar/types.ts new file mode 100644 index 000000000..38c5ba580 --- /dev/null +++ b/packages/lib/src/avatar/types.ts @@ -0,0 +1,64 @@ +import { SVG } from "../common/utils"; + +type Size = "xsmall" | "small" | "medium" | "large" | "xlarge" | "xxlarge"; +type Shape = "circle" | "square"; +type Color = "primary" | "secondary" | "tertiary" | "success" | "info" | "neutral" | "warning" | "error"; +export interface Status { + mode: "default" | "info" | "success" | "warning" | "error"; + position: "top" | "bottom"; +} + +type Props = { + /** + * Affects the visual style of the avatar. It can be used following semantic purposes or not. + */ + color?: Color; + /** + * If true, the component will be disabled. + */ + disabled?: boolean; + /** + * Material Symbol name or SVG element as the icon that will be placed as avatar. + */ + icon?: string | SVG; + /** + * URL of the image. + */ + imageSrc?: string; + /** + * Text label associated with the avatar. + * Used to generate and display initials inside the avatar. + */ + label?: string; + /** + * Page to be opened when the user clicks on the link. + */ + linkHref?: string; + /** + * This function will be called when the user clicks the avatar. Makes it behave as a button. + */ + onClick?: () => void; + /** + * This will determine if the avatar will be rounded square or a circle. + */ + shape?: Shape; + /** + * Size of the component. + */ + size?: Size; + /** + * Defines the color of the status indicator displayed on the avatar and where it will be placed. + * If not provided, no indicator will be rendered. + */ + status?: Status; + /** + * Value of the tabindex attribute. It will only apply when the onClick property is passed. + */ + tabIndex?: number; + /** + * Text to be displayed inside a tooltip when hovering the avatar. + */ + title?: string; +}; + +export default Props; diff --git a/packages/lib/src/avatar/utils.ts b/packages/lib/src/avatar/utils.ts new file mode 100644 index 000000000..904062128 --- /dev/null +++ b/packages/lib/src/avatar/utils.ts @@ -0,0 +1,139 @@ +import AvatarPropsType from "./types"; + +const contextualColorMap = { + primary: { + background: "var(--color-bg-primary-lighter)", + text: "var(--color-fg-primary-stronger)", + }, + secondary: { + background: "var(--color-bg-secondary-lighter)", + text: "var(--color-fg-secondary-stronger)", + }, + tertiary: { + background: "var(--color-bg-yellow-light)", + text: "var(--color-fg-neutral-yellow-dark)", + }, + neutral: { + background: "var(--color-bg-neutral-light)", + text: "var(--color-fg-neutral-strongest)", + }, + info: { + background: "var(--color-bg-info-lighter)", + text: "var(--color-fg-info-stronger)", + }, + success: { + background: "var(--color-bg-success-lighter)", + text: "var(--color-fg-success-stronger)", + }, + warning: { + background: "var(--color-bg-warning-lighter)", + text: "var(--color-fg-warning-stronger)", + }, + error: { + background: "var(--color-bg-error-lighter)", + text: "var(--color-fg-error-stronger)", + }, +}; + +const borderRadiusMap = { + xsmall: "var(--border-radius-xs)", + small: "var(--border-radius-s)", + medium: "var(--border-radius-m)", + large: "var(--border-radius-m)", + xlarge: "var(--border-radius-l)", + xxlarge: "var(--border-radius-l)", +}; + +const sizeMap = { + xsmall: "var(--height-s)", + small: "var(--height-m)", + medium: "var(--height-xl)", + large: "var(--height-xxxl)", + xlarge: "72px", + xxlarge: "80px", +}; + +const fontSizeMap = { + xsmall: "var(--typography-label-s)", + small: "var(--typography-label-m)", + medium: "var(--typography-label-l)", + large: "var(--typography-label-xl)", + xlarge: "32px", + xxlarge: "36px", +}; + +const iconSizeMap = { + xsmall: "var(--height-xxs)", + small: "var(--height-xs)", + medium: "var(--height-s)", + large: "var(--height-xl)", + xlarge: "var(--height-xxl)", + xxlarge: "52px", +}; + +const outlineWidthMap = { + xsmall: "var(--border-width-m)", + small: "var(--border-width-m)", + medium: "var(--border-width-m)", + large: "var(--border-width-l)", + xlarge: "var(--border-width-l)", + xxlarge: "var(--border-width-l)", +}; + +const borderWidthMap = { + xsmall: "var(--border-width-s)", + small: "var(--border-width-s)", + medium: "var(--border-width-s)", + large: "var(--border-width-m)", + xlarge: "var(--border-width-m)", + xxlarge: "var(--border-width-m)", +}; + +const modeColorMap = { + default: "var(--color-fg-neutral-strong)", + info: "var(--color-fg-secondary-medium)", + success: "var(--color-fg-success-medium)", + warning: "var(--color-fg-warning-strong)", + error: "var(--color-fg-error-medium)", +}; + +export const getColor = (color: AvatarPropsType["color"]) => (color ? contextualColorMap[color].text : undefined); +export const getBackgroundColor = (color: AvatarPropsType["color"]) => + color ? contextualColorMap[color].background : undefined; + +export const getBorderRadius = (shape: AvatarPropsType["shape"], size: AvatarPropsType["size"]) => { + if (shape === "circle") { + return "100%"; + } + if (shape === "square") { + return size ? borderRadiusMap[size] : "var(--border-radius-m)"; + } + return "100%"; +}; + +export const getSize = (size: AvatarPropsType["size"]) => (size ? sizeMap[size] : "var(--height-xl)"); + +export const getFontSize = (size: AvatarPropsType["size"]) => (size ? fontSizeMap[size] : "var(--typography-label-l)"); + +export const getIconSize = (size: AvatarPropsType["size"]) => (size ? iconSizeMap[size] : "var(--height-s)"); + +export const getBorderWidth = (size: AvatarPropsType["size"]) => + size ? borderWidthMap[size] : "var(--border-width-s)"; + +export const getOutlineWidth = (size: AvatarPropsType["size"]) => + size ? outlineWidthMap[size] : "var(--border-width-m)"; + +export const getModeColor = (mode: Required<AvatarPropsType>["status"]["mode"]) => + mode ? modeColorMap[mode] : "var(--color-fg-neutral-strong)"; + +export const getInitials = (label?: string): string => { + if (!label) return ""; + const words = label.trim().split(/\s+/); + if (words.length >= 2) { + const firstChar = words[0]?.[0] ?? ""; + const secondChar = words[1]?.[0] ?? ""; + return (firstChar + secondChar).toUpperCase(); + } + const firstWord = words[0] ?? ""; + return firstWord.slice(0, 2).toUpperCase(); +}; diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts index 57b9ab3fe..78c3eb941 100644 --- a/packages/lib/src/index.ts +++ b/packages/lib/src/index.ts @@ -4,6 +4,7 @@ import "./styles/variables.css"; export { default as DxcAccordion } from "./accordion/Accordion"; export { default as DxcAlert } from "./alert/Alert"; export { default as DxcApplicationLayout } from "./layout/ApplicationLayout"; +export { default as DxcAvatar } from "./avatar/Avatar"; export { default as DxcBadge } from "./badge/Badge"; export { default as DxcBleed } from "./bleed/Bleed"; export { default as DxcBreadcrumbs } from "./breadcrumbs/Breadcrumbs";