mirror of
https://github.com/coder/coder.git
synced 2026-11-11 11:31:48 +08:00
chore: add new avatar component (#15882)
Related to https://github.com/coder/coder/issues/14997 - Add a new `Avatar` component based on the [new avatar design](https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=711-383&t=xqxOSUk48GvDsjGK-0). - Deprecate existent `Avatar` component.
This commit is contained in:
@@ -1,59 +1,73 @@
|
||||
import PauseIcon from "@mui/icons-material/PauseOutlined";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Avatar, AvatarIcon } from "./Avatar";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "./Avatar";
|
||||
|
||||
const meta: Meta<typeof Avatar> = {
|
||||
title: "components/Avatar",
|
||||
component: Avatar,
|
||||
args: {
|
||||
children: <AvatarImage src="https://github.com/kylecarbs.png" />,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Avatar>;
|
||||
|
||||
export const WithLetter: Story = {
|
||||
export const ImageLgSize: Story = {
|
||||
args: { size: "lg" },
|
||||
};
|
||||
|
||||
export const ImageDefaultSize: Story = {};
|
||||
|
||||
export const ImageSmSize: Story = {
|
||||
args: { size: "sm" },
|
||||
};
|
||||
|
||||
export const IconLgSize: Story = {
|
||||
args: {
|
||||
children: "Coder",
|
||||
size: "lg",
|
||||
variant: "icon",
|
||||
children: (
|
||||
<AvatarImage src="https://em-content.zobj.net/source/apple/391/billed-cap_1f9e2.png" />
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLetterXL = {
|
||||
export const IconDefaultSize: Story = {
|
||||
args: {
|
||||
children: "Coder",
|
||||
size: "xl",
|
||||
variant: "icon",
|
||||
children: (
|
||||
<AvatarImage src="https://em-content.zobj.net/source/apple/391/billed-cap_1f9e2.png" />
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithImage = {
|
||||
export const IconSmSize: Story = {
|
||||
args: {
|
||||
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
|
||||
variant: "icon",
|
||||
size: "sm",
|
||||
children: (
|
||||
<AvatarImage src="https://em-content.zobj.net/source/apple/391/billed-cap_1f9e2.png" />
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithImageXL = {
|
||||
export const FallbackLgSize: Story = {
|
||||
args: {
|
||||
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
|
||||
size: "xl",
|
||||
size: "lg",
|
||||
|
||||
children: <AvatarFallback>AR</AvatarFallback>,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithMuiIcon = {
|
||||
export const FallbackDefaultSize: Story = {
|
||||
args: {
|
||||
background: true,
|
||||
children: <PauseIcon />,
|
||||
children: <AvatarFallback>AR</AvatarFallback>,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithMuiIconXL = {
|
||||
export const FallbackSmSize: Story = {
|
||||
args: {
|
||||
background: true,
|
||||
children: <PauseIcon />,
|
||||
size: "xl",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAvatarIcon = {
|
||||
args: {
|
||||
background: true,
|
||||
children: <AvatarIcon src="/icon/database.svg" alt="Database" />,
|
||||
size: "sm",
|
||||
children: <AvatarFallback>AR</AvatarFallback>,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,116 +1,94 @@
|
||||
import { type Interpolation, type Theme, css, useTheme } from "@emotion/react";
|
||||
import MuiAvatar, {
|
||||
type AvatarProps as MuiAvatarProps,
|
||||
// biome-ignore lint/nursery/noRestrictedImports: Used as base component
|
||||
} from "@mui/material/Avatar";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import { type FC, useId } from "react";
|
||||
import { getExternalImageStylesFromUrl } from "theme/externalImages";
|
||||
|
||||
export type AvatarProps = MuiAvatarProps & {
|
||||
size?: "xs" | "sm" | "md" | "xl";
|
||||
background?: boolean;
|
||||
fitImage?: boolean;
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
xs: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
},
|
||||
sm: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
},
|
||||
md: {},
|
||||
xl: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
fontSize: 24,
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
const fitImageStyles = css`
|
||||
& .MuiAvatar-img {
|
||||
object-fit: contain;
|
||||
}
|
||||
`;
|
||||
|
||||
export const Avatar: FC<AvatarProps> = ({
|
||||
size = "md",
|
||||
fitImage,
|
||||
children,
|
||||
background,
|
||||
...muiProps
|
||||
}) => {
|
||||
const fromName = !muiProps.src && typeof children === "string";
|
||||
|
||||
return (
|
||||
<MuiAvatar
|
||||
{...muiProps}
|
||||
css={[
|
||||
sizeStyles[size],
|
||||
fitImage && fitImageStyles,
|
||||
(theme) => ({
|
||||
background:
|
||||
background || fromName ? theme.palette.divider : undefined,
|
||||
color: theme.palette.text.primary,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{typeof children === "string" ? firstLetter(children) : children}
|
||||
</MuiAvatar>
|
||||
);
|
||||
};
|
||||
|
||||
export const ExternalAvatar: FC<AvatarProps> = (props) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
css={getExternalImageStylesFromUrl(theme.externalImages, props.src)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type AvatarIconProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
/**
|
||||
* Use it to make an img element behaves like a MaterialUI Icon component
|
||||
* Copied from shadc/ui on 12/16/2024
|
||||
* @see {@link https://ui.shadcn.com/docs/components/avatar}
|
||||
*
|
||||
* This component was updated to support the variants and match the styles from
|
||||
* the Figma design:
|
||||
* @see {@link https://www.figma.com/design/WfqIgsTFXN2BscBSSyXWF8/Coder-kit?node-id=711-383&t=xqxOSUk48GvDsjGK-0}
|
||||
*/
|
||||
export const AvatarIcon: FC<AvatarIconProps> = ({ src, alt }) => {
|
||||
const hookId = useId();
|
||||
const avatarId = `${hookId}-avatar`;
|
||||
import * as React from "react";
|
||||
import { cn } from "utils/cn";
|
||||
|
||||
// We use a `visuallyHidden` element instead of setting `alt` to avoid
|
||||
// splatting the text out on the screen if the image fails to load.
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
css={{ maxWidth: "50%" }}
|
||||
aria-labelledby={avatarId}
|
||||
/>
|
||||
<div id={avatarId} css={{ ...visuallyHidden }}>
|
||||
{alt}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const avatarVariants = cva(
|
||||
"relative flex shrink-0 overflow-hidden rounded border border-solid bg-surface-secondary text-content-secondary",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
lg: "h-10 w-10 rounded-[6px] text-sm font-medium",
|
||||
default: "h-6 w-6 text-2xs",
|
||||
sm: "h-[18px] w-[18px] text-[8px]",
|
||||
},
|
||||
variant: {
|
||||
default: "",
|
||||
icon: "",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "default",
|
||||
},
|
||||
compoundVariants: [
|
||||
{
|
||||
size: "lg",
|
||||
variant: "icon",
|
||||
className: "p-[9px]",
|
||||
},
|
||||
{
|
||||
size: "default",
|
||||
variant: "icon",
|
||||
className: "p-[3px]",
|
||||
},
|
||||
{
|
||||
size: "sm",
|
||||
variant: "icon",
|
||||
className: "p-[2px]",
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
const firstLetter = (str: string): string => {
|
||||
if (str.length > 0) {
|
||||
return str[0].toLocaleUpperCase();
|
||||
}
|
||||
export interface AvatarProps
|
||||
extends React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>,
|
||||
VariantProps<typeof avatarVariants> {}
|
||||
|
||||
return "";
|
||||
};
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Root>,
|
||||
AvatarProps
|
||||
>(({ className, size, variant, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(avatarVariants({ size, variant, className }))}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Avatar.displayName = AvatarPrimitive.Root.displayName;
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
className={cn("aspect-square h-full w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarImage.displayName = AvatarPrimitive.Image.displayName;
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type CSSObject, useTheme } from "@emotion/react";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { Avatar } from "components/deprecated/Avatar/Avatar";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
type AvatarCardProps = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTheme } from "@emotion/react";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { Stack } from "components/Stack/Stack";
|
||||
import { Avatar } from "components/deprecated/Avatar/Avatar";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
export interface AvatarDataProps {
|
||||
|
||||
@@ -2,8 +2,8 @@ import { css, cx } from "@emotion/css";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import Badge from "@mui/material/Badge";
|
||||
import type { WorkspaceBuild } from "api/typesGenerated";
|
||||
import { Avatar, type AvatarProps } from "components/Avatar/Avatar";
|
||||
import { BuildIcon } from "components/BuildIcon/BuildIcon";
|
||||
import { Avatar, type AvatarProps } from "components/deprecated/Avatar/Avatar";
|
||||
import { useClassName } from "hooks/useClassName";
|
||||
import type { FC } from "react";
|
||||
import { getDisplayWorkspaceBuildStatus } from "utils/workspace";
|
||||
|
||||
@@ -2,7 +2,10 @@ import { css } from "@emotion/css";
|
||||
import { useTheme } from "@emotion/react";
|
||||
import Button, { type ButtonProps } from "@mui/material/Button";
|
||||
import IconButton, { type IconButtonProps } from "@mui/material/IconButton";
|
||||
import { type AvatarProps, ExternalAvatar } from "components/Avatar/Avatar";
|
||||
import {
|
||||
type AvatarProps,
|
||||
ExternalAvatar,
|
||||
} from "components/deprecated/Avatar/Avatar";
|
||||
import {
|
||||
type FC,
|
||||
type ForwardedRef,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Group from "@mui/icons-material/Group";
|
||||
import Badge from "@mui/material/Badge";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { Avatar } from "components/deprecated/Avatar/Avatar";
|
||||
import { type ClassName, useClassName } from "hooks/useClassName";
|
||||
import type { FC } from "react";
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ import TextField from "@mui/material/TextField";
|
||||
import { checkAuthorization } from "api/queries/authCheck";
|
||||
import { organizations } from "api/queries/organizations";
|
||||
import type { AuthorizationCheck, Organization } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/AvatarData/AvatarData";
|
||||
import { Avatar } from "components/deprecated/Avatar/Avatar";
|
||||
import { useDebouncedFunction } from "hooks/debounce";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Template } from "api/typesGenerated";
|
||||
import { Avatar, type AvatarProps } from "components/Avatar/Avatar";
|
||||
import { Avatar, type AvatarProps } from "components/deprecated/Avatar/Avatar";
|
||||
import type { FC } from "react";
|
||||
|
||||
interface TemplateAvatarProps extends AvatarProps {
|
||||
|
||||
@@ -6,8 +6,8 @@ import { getErrorMessage } from "api/errors";
|
||||
import { organizationMembers } from "api/queries/organizations";
|
||||
import { users } from "api/queries/users";
|
||||
import type { OrganizationMemberWithUserData, User } from "api/typesGenerated";
|
||||
import { Avatar } from "components/Avatar/Avatar";
|
||||
import { AvatarData } from "components/AvatarData/AvatarData";
|
||||
import { Avatar } from "components/deprecated/Avatar/Avatar";
|
||||
import { useDebouncedFunction } from "hooks/debounce";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Avatar, type AvatarProps } from "components/Avatar/Avatar";
|
||||
import { Avatar, type AvatarProps } from "components/deprecated/Avatar/Avatar";
|
||||
import type { FC } from "react";
|
||||
|
||||
export type UserAvatarProps = {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import PauseIcon from "@mui/icons-material/PauseOutlined";
|
||||
import type { Meta, StoryObj } from "@storybook/react";
|
||||
import { Avatar, AvatarIcon } from "./Avatar";
|
||||
|
||||
const meta: Meta<typeof Avatar> = {
|
||||
title: "components/DeprecatedAvatar",
|
||||
component: Avatar,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Avatar>;
|
||||
|
||||
export const WithLetter: Story = {
|
||||
args: {
|
||||
children: "Coder",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLetterXL = {
|
||||
args: {
|
||||
children: "Coder",
|
||||
size: "xl",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithImage = {
|
||||
args: {
|
||||
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithImageXL = {
|
||||
args: {
|
||||
src: "https://avatars.githubusercontent.com/u/95932066?s=200&v=4",
|
||||
size: "xl",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithMuiIcon = {
|
||||
args: {
|
||||
background: true,
|
||||
children: <PauseIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithMuiIconXL = {
|
||||
args: {
|
||||
background: true,
|
||||
children: <PauseIcon />,
|
||||
size: "xl",
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAvatarIcon = {
|
||||
args: {
|
||||
background: true,
|
||||
children: <AvatarIcon src="/icon/database.svg" alt="Database" />,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { type Interpolation, type Theme, css, useTheme } from "@emotion/react";
|
||||
import MuiAvatar, {
|
||||
type AvatarProps as MuiAvatarProps,
|
||||
// biome-ignore lint/nursery/noRestrictedImports: Used as base component
|
||||
} from "@mui/material/Avatar";
|
||||
import { visuallyHidden } from "@mui/utils";
|
||||
import { type FC, useId } from "react";
|
||||
import { getExternalImageStylesFromUrl } from "theme/externalImages";
|
||||
|
||||
export type AvatarProps = MuiAvatarProps & {
|
||||
size?: "xs" | "sm" | "md" | "xl";
|
||||
background?: boolean;
|
||||
fitImage?: boolean;
|
||||
};
|
||||
|
||||
const sizeStyles = {
|
||||
xs: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
fontSize: 8,
|
||||
fontWeight: 700,
|
||||
},
|
||||
sm: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
},
|
||||
md: {},
|
||||
xl: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
fontSize: 24,
|
||||
},
|
||||
} satisfies Record<string, Interpolation<Theme>>;
|
||||
|
||||
const fitImageStyles = css`
|
||||
& .MuiAvatar-img {
|
||||
object-fit: contain;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* @deprecated Use `Avatar` from `@components/Avatar` instead.
|
||||
*/
|
||||
export const Avatar: FC<AvatarProps> = ({
|
||||
size = "md",
|
||||
fitImage,
|
||||
children,
|
||||
background,
|
||||
...muiProps
|
||||
}) => {
|
||||
const fromName = !muiProps.src && typeof children === "string";
|
||||
|
||||
return (
|
||||
<MuiAvatar
|
||||
{...muiProps}
|
||||
css={[
|
||||
sizeStyles[size],
|
||||
fitImage && fitImageStyles,
|
||||
(theme) => ({
|
||||
background:
|
||||
background || fromName ? theme.palette.divider : undefined,
|
||||
color: theme.palette.text.primary,
|
||||
}),
|
||||
]}
|
||||
>
|
||||
{typeof children === "string" ? firstLetter(children) : children}
|
||||
</MuiAvatar>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* @deprecated Use `Avatar` from `@components/Avatar` instead.
|
||||
*/
|
||||
export const ExternalAvatar: FC<AvatarProps> = (props) => {
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
css={getExternalImageStylesFromUrl(theme.externalImages, props.src)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
type AvatarIconProps = {
|
||||
src: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Use it to make an img element behaves like a MaterialUI Icon component
|
||||
*
|
||||
* @deprecated Use `AvatarIcon` from `@components/Avatar` instead.
|
||||
*/
|
||||
export const AvatarIcon: FC<AvatarIconProps> = ({ src, alt }) => {
|
||||
const hookId = useId();
|
||||
const avatarId = `${hookId}-avatar`;
|
||||
|
||||
// We use a `visuallyHidden` element instead of setting `alt` to avoid
|
||||
// splatting the text out on the screen if the image fails to load.
|
||||
return (
|
||||
<>
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
css={{ maxWidth: "50%" }}
|
||||
aria-labelledby={avatarId}
|
||||
/>
|
||||
<div id={avatarId} css={{ ...visuallyHidden }}>
|
||||
{alt}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const firstLetter = (str: string): string => {
|
||||
if (str.length > 0) {
|
||||
return str[0].toLocaleUpperCase();
|
||||
}
|
||||
|
||||
return "";
|
||||
};
|
||||
Reference in New Issue
Block a user