mirror of
https://github.com/penpot/penpot.git
synced 2026-04-28 04:38:14 +00:00
✨ Add Avatar component to @penpot/ui
This commit is contained in:
parent
b5803871a7
commit
c22c45384b
@ -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` |
|
||||
|
||||
@ -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";
|
||||
|
||||
53
frontend/packages/ui/src/lib/product/Avatar.module.scss
Normal file
53
frontend/packages/ui/src/lib/product/Avatar.module.scss
Normal 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);
|
||||
}
|
||||
101
frontend/packages/ui/src/lib/product/Avatar.spec.tsx
Normal file
101
frontend/packages/ui/src/lib/product/Avatar.spec.tsx
Normal 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
61
frontend/packages/ui/src/lib/product/Avatar.stories.tsx
Normal file
61
frontend/packages/ui/src/lib/product/Avatar.stories.tsx
Normal 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 },
|
||||
};
|
||||
106
frontend/packages/ui/src/lib/product/Avatar.tsx
Normal file
106
frontend/packages/ui/src/lib/product/Avatar.tsx
Normal 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);
|
||||
Loading…
x
Reference in New Issue
Block a user