Add Avatar component to @penpot/ui

This commit is contained in:
Andrey Antukh 2026-04-07 20:56:27 +00:00
parent b5803871a7
commit c22c45384b
6 changed files with 328 additions and 0 deletions

View File

@ -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` |

View File

@ -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";

View File

@ -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);
}

View File

@ -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(<Avatar profile={profile} />);
expect(baseElement).toBeTruthy();
});
it("should render a div root by default", () => {
const { container } = render(<Avatar profile={profile} />);
expect(container.firstElementChild?.tagName.toLowerCase()).toBe("div");
});
it("should render with a custom tag", () => {
const { container } = render(<Avatar profile={profile} tag="button" />);
expect(container.firstElementChild?.tagName.toLowerCase()).toBe("button");
});
it("should apply avatar class to root element", () => {
const { container } = render(<Avatar profile={profile} />);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"avatar",
);
});
it("should apply avatar-small class by default", () => {
const { container } = render(<Avatar profile={profile} />);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"avatar-small",
);
});
it("should apply avatar-medium class when variant='M'", () => {
const { container } = render(<Avatar profile={profile} variant="M" />);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"avatar-medium",
);
});
it("should apply avatar-large class when variant='L'", () => {
const { container } = render(<Avatar profile={profile} variant="L" />);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"avatar-large",
);
});
it("should apply is-selected class when selected=true", () => {
const { container } = render(<Avatar profile={profile} selected />);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"is-selected",
);
});
it("should set title to profile fullname on root element", () => {
const { container } = render(<Avatar profile={profile} />);
expect(container.firstElementChild?.getAttribute("title")).toBe(
"Ada Lovelace",
);
});
it("should render img with alt text from fullname", () => {
const { container } = render(<Avatar profile={profile} />);
const img = container.querySelector("img");
expect(img?.getAttribute("alt")).toBe("Ada Lovelace");
});
it("should use photoUrl as img src when provided", () => {
const { container } = render(<Avatar profile={profileWithPhoto} />);
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(<Avatar profile={profile} />);
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(
<Avatar profile={profile} className="custom-cls" />,
);
expect(container.firstElementChild?.getAttribute("class")).toContain(
"custom-cls",
);
});
});

View File

@ -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<typeof Avatar>;
export default meta;
type Story = StoryObj<typeof meta>;
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 },
};

View File

@ -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 = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64"><rect width="64" height="64" fill="${color}"/><text x="50%" y="67%" text-anchor="middle" font-family="Arial, sans-serif" font-size="28" fill="#fff">${initials}</text></svg>`;
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 (
<Tag className={rootClass} title={profile.fullname}>
<div className={styles["avatar-image"]}>
<img alt={profile.fullname} src={src} />
</div>
</Tag>
);
}
export const Avatar = memo(AvatarInner);