From c22c45384bd72a42b62aaa22f985622e4fe17e8b Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 7 Apr 2026 20:56:27 +0000 Subject: [PATCH] :sparkles: Add Avatar component to @penpot/ui --- frontend/packages/ui/AGENTS.md | 1 + frontend/packages/ui/src/index.ts | 6 + .../ui/src/lib/product/Avatar.module.scss | 53 +++++++++ .../ui/src/lib/product/Avatar.spec.tsx | 101 +++++++++++++++++ .../ui/src/lib/product/Avatar.stories.tsx | 61 ++++++++++ .../packages/ui/src/lib/product/Avatar.tsx | 106 ++++++++++++++++++ 6 files changed, 328 insertions(+) create mode 100644 frontend/packages/ui/src/lib/product/Avatar.module.scss create mode 100644 frontend/packages/ui/src/lib/product/Avatar.spec.tsx create mode 100644 frontend/packages/ui/src/lib/product/Avatar.stories.tsx create mode 100644 frontend/packages/ui/src/lib/product/Avatar.tsx diff --git a/frontend/packages/ui/AGENTS.md b/frontend/packages/ui/AGENTS.md index 963fb90ca1..2675489420 100644 --- a/frontend/packages/ui/AGENTS.md +++ b/frontend/packages/ui/AGENTS.md @@ -64,6 +64,7 @@ Components are organised to mirror the CLJS source tree | `ds/foundations/assets/raw_svg.cljs` | `src/lib/foundations/assets/RawSvg.tsx` | | `ds/product/cta.cljs` | `src/lib/product/Cta.tsx` | | `ds/product/loader.cljs` | `src/lib/product/Loader.tsx` | +| `ds/product/avatar.cljs` | `src/lib/product/Avatar.tsx` | | `ds/buttons/button.cljs` | `src/lib/buttons/Button.tsx` | | `ds/buttons/icon_button.cljs` | `src/lib/buttons/IconButton.tsx` | | `ds/utilities/swatch.cljs` | `src/lib/utilities/Swatch.tsx` | diff --git a/frontend/packages/ui/src/index.ts b/frontend/packages/ui/src/index.ts index cb581766a9..ea2dcb6b2f 100644 --- a/frontend/packages/ui/src/index.ts +++ b/frontend/packages/ui/src/index.ts @@ -13,6 +13,12 @@ export { Cta } from "./lib/product/Cta"; export type { CtaProps } from "./lib/product/Cta"; export { Loader } from "./lib/product/Loader"; export type { LoaderProps, LoaderTip } from "./lib/product/Loader"; +export { Avatar } from "./lib/product/Avatar"; +export type { + AvatarProps, + AvatarProfile, + AvatarVariant, +} from "./lib/product/Avatar"; export { Icon, iconIds } from "./lib/foundations/assets/Icon"; export type { IconId, IconProps } from "./lib/foundations/assets/Icon"; export { RawSvg, rawSvgIds } from "./lib/foundations/assets/RawSvg"; diff --git a/frontend/packages/ui/src/lib/product/Avatar.module.scss b/frontend/packages/ui/src/lib/product/Avatar.module.scss new file mode 100644 index 0000000000..caa5017ca9 --- /dev/null +++ b/frontend/packages/ui/src/lib/product/Avatar.module.scss @@ -0,0 +1,53 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "../_ds/_sizes.scss" as *; +@use "../_ds/_borders.scss" as *; + +.avatar { + border-radius: $br-circle; + overflow: hidden; + border: $b-1 solid var(--border-color, none); + + &:hover, + &:focus { + --border-color: var(--color-accent-primary); + } +} + +.avatar-small { + inline-size: $sz-24; + block-size: $sz-24; +} + +.avatar-medium { + inline-size: $sz-32; + block-size: $sz-32; +} + +.avatar-large { + inline-size: $sz-40; + block-size: $sz-40; +} + +.avatar-image { + overflow: hidden; + border-radius: $br-circle; + background-color: var(--avatar-color); + + img { + inline-size: 100%; + block-size: 100%; + object-fit: cover; + display: block; + } +} + +.is-selected { + --border-color: var(--color-accent-primary); + + padding: var(--sp-xxs); +} diff --git a/frontend/packages/ui/src/lib/product/Avatar.spec.tsx b/frontend/packages/ui/src/lib/product/Avatar.spec.tsx new file mode 100644 index 0000000000..ef123d24a4 --- /dev/null +++ b/frontend/packages/ui/src/lib/product/Avatar.spec.tsx @@ -0,0 +1,101 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { Avatar } from "./Avatar"; + +const profile = { fullname: "Ada Lovelace" }; +const profileWithPhoto = { + fullname: "Ada Lovelace", + photoUrl: "https://example.com/photo.jpg", +}; + +describe("Avatar", () => { + it("should render successfully", () => { + const { baseElement } = render(); + expect(baseElement).toBeTruthy(); + }); + + it("should render a div root by default", () => { + const { container } = render(); + expect(container.firstElementChild?.tagName.toLowerCase()).toBe("div"); + }); + + it("should render with a custom tag", () => { + const { container } = render(); + expect(container.firstElementChild?.tagName.toLowerCase()).toBe("button"); + }); + + it("should apply avatar class to root element", () => { + const { container } = render(); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "avatar", + ); + }); + + it("should apply avatar-small class by default", () => { + const { container } = render(); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "avatar-small", + ); + }); + + it("should apply avatar-medium class when variant='M'", () => { + const { container } = render(); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "avatar-medium", + ); + }); + + it("should apply avatar-large class when variant='L'", () => { + const { container } = render(); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "avatar-large", + ); + }); + + it("should apply is-selected class when selected=true", () => { + const { container } = render(); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "is-selected", + ); + }); + + it("should set title to profile fullname on root element", () => { + const { container } = render(); + expect(container.firstElementChild?.getAttribute("title")).toBe( + "Ada Lovelace", + ); + }); + + it("should render img with alt text from fullname", () => { + const { container } = render(); + const img = container.querySelector("img"); + expect(img?.getAttribute("alt")).toBe("Ada Lovelace"); + }); + + it("should use photoUrl as img src when provided", () => { + const { container } = render(); + const img = container.querySelector("img"); + expect(img?.getAttribute("src")).toBe("https://example.com/photo.jpg"); + }); + + it("should generate a data URI src when no photoUrl provided", () => { + const { container } = render(); + const img = container.querySelector("img"); + expect(img?.getAttribute("src")).toMatch(/^data:image\/svg\+xml;base64,/); + }); + + it("should forward className to the root element", () => { + const { container } = render( + , + ); + expect(container.firstElementChild?.getAttribute("class")).toContain( + "custom-cls", + ); + }); +}); diff --git a/frontend/packages/ui/src/lib/product/Avatar.stories.tsx b/frontend/packages/ui/src/lib/product/Avatar.stories.tsx new file mode 100644 index 0000000000..995940875d --- /dev/null +++ b/frontend/packages/ui/src/lib/product/Avatar.stories.tsx @@ -0,0 +1,61 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { Avatar } from "./Avatar"; + +const profile = { + fullname: "Ada Lovelace", + photoUrl: "/images/avatar-blue.jpg", +}; + +const profileNoPhoto = { + fullname: "Ada Lovelace", +}; + +const meta = { + title: "Product/Avatar", + component: Avatar, + args: { + profile, + variant: "S", + selected: false, + }, + argTypes: { + variant: { + options: ["S", "M", "L"], + control: { type: "select" }, + }, + selected: { control: "boolean" }, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const NoURL: Story = { + args: { + profile: profileNoPhoto, + }, +}; + +export const Small: Story = { + args: { variant: "S" }, +}; + +export const Medium: Story = { + args: { variant: "M" }, +}; + +export const Large: Story = { + args: { variant: "L" }, +}; + +export const Selected: Story = { + args: { selected: true }, +}; diff --git a/frontend/packages/ui/src/lib/product/Avatar.tsx b/frontend/packages/ui/src/lib/product/Avatar.tsx new file mode 100644 index 0000000000..fa33cd45a1 --- /dev/null +++ b/frontend/packages/ui/src/lib/product/Avatar.tsx @@ -0,0 +1,106 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +import { type ElementType, memo, useMemo } from "react"; +import clsx from "clsx"; +import styles from "./Avatar.module.scss"; + +// --------------------------------------------------------------------------- +// Initials avatar generator (SVG-based, mirrors app.util.avatars/generate*) +// --------------------------------------------------------------------------- + +const AVATAR_COLORS = [ + "#5b57ff", + "#b1b1e5", + "#e55b5b", + "#e5a45b", + "#5be587", + "#5bd6e5", +]; + +function hashString(s: string): number { + let hash = 0; + for (let i = 0; i < s.length; i++) { + hash = (hash << 5) - hash + s.charCodeAt(i); + hash |= 0; + } + return Math.abs(hash); +} + +function getInitials(name: string): string { + const parts = name.trim().toUpperCase().split(/\s+/); + if (parts.length === 0 || parts[0] === "") return "?"; + if (parts.length === 1) return parts[0][0] ?? "?"; + return (parts[0][0] ?? "") + (parts[1][0] ?? ""); +} + +function generateAvatarDataUri(name: string): string { + const initials = getInitials(name); + const color = AVATAR_COLORS[hashString(name) % AVATAR_COLORS.length]; + const svg = `${initials}`; + return `data:image/svg+xml;base64,${btoa(svg)}`; +} + +// --------------------------------------------------------------------------- +// Profile type +// --------------------------------------------------------------------------- + +export interface AvatarProfile { + fullname: string; + /** Pre-resolved photo URL. Takes precedence over photoId. */ + photoUrl?: string; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export type AvatarVariant = "S" | "M" | "L"; + +export interface AvatarProps { + profile: AvatarProfile; + /** Size variant. Defaults to "S". */ + variant?: AvatarVariant; + /** Whether the avatar shows a selected ring. */ + selected?: boolean; + /** Override the root element tag. Defaults to "div". */ + tag?: ElementType; + /** Additional CSS class names */ + className?: string; +} + +function AvatarInner({ + profile, + variant = "S", + selected = false, + tag: Tag = "div", + className, +}: AvatarProps) { + const src = useMemo(() => { + return profile.photoUrl ?? generateAvatarDataUri(profile.fullname); + }, [profile.photoUrl, profile.fullname]); + + const rootClass = clsx( + styles.avatar, + { + [styles["avatar-small"]]: variant === "S", + [styles["avatar-medium"]]: variant === "M", + [styles["avatar-large"]]: variant === "L", + [styles["is-selected"]]: selected, + }, + className, + ); + + return ( + +
+ {profile.fullname} +
+
+ ); +} + +export const Avatar = memo(AvatarInner);