Skip to content

Commit 62008bd

Browse files
authored
feat(components): add Avatar (#1519)
* feat(components): add `Avatar` * chore: add changeset * fix: sizing * refactor: split up variants * fix: show fallbacks on idle and loading * fix: remove icon variant
1 parent e22b7e7 commit 62008bd

File tree

7 files changed

+271
-3
lines changed

7 files changed

+271
-3
lines changed

.changeset/witty-kangaroos-grin.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@launchpad-ui/components": patch
3+
---
4+
5+
Add `Avatar`
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
2+
3+
import { render, screen } from '../../../test/utils';
4+
import { Avatar } from '../src';
5+
6+
class MockImage {
7+
onload: () => void = () => {};
8+
src = '';
9+
constructor() {
10+
setTimeout(() => {
11+
this.onload();
12+
}, 300);
13+
// biome-ignore lint/correctness/noConstructorReturn: <explanation>
14+
return this;
15+
}
16+
}
17+
18+
class ErrorImage {
19+
onerror: () => void = () => {};
20+
src = '';
21+
constructor() {
22+
setTimeout(() => {
23+
this.onerror();
24+
}, 300);
25+
// biome-ignore lint/correctness/noConstructorReturn: <explanation>
26+
return this;
27+
}
28+
}
29+
30+
describe('Avatar', () => {
31+
const orignalGlobalImage = window.Image;
32+
33+
beforeAll(() => {
34+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
35+
(window.Image as any) = MockImage;
36+
});
37+
38+
afterAll(() => {
39+
window.Image = orignalGlobalImage;
40+
});
41+
42+
it('renders', async () => {
43+
render(<Avatar src="https://avatars.githubusercontent.com/u/2147624?v=4" alt="engineer" />);
44+
expect(await screen.findByRole('img', { name: 'engineer' })).toBeVisible();
45+
});
46+
47+
it('renders initials on error', async () => {
48+
// biome-ignore lint/suspicious/noExplicitAny: <explanation>
49+
(window.Image as any) = ErrorImage;
50+
render(<Avatar src="https://avatars.githubusercontent.com/u/00000">RN</Avatar>);
51+
expect(await screen.findByRole('img')).toHaveTextContent('RN');
52+
});
53+
});

packages/components/src/Avatar.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type { VariantProps } from 'class-variance-authority';
2+
import type { ImgHTMLAttributes, RefObject, SVGAttributes } from 'react';
3+
4+
import { cva, cx } from 'class-variance-authority';
5+
6+
import styles from './styles/Avatar.module.css';
7+
import { useImageLoadingStatus } from './utils';
8+
9+
const avatar = cva(styles.base, {
10+
variants: {
11+
size: {
12+
small: styles.small,
13+
medium: styles.medium,
14+
large: styles.large,
15+
},
16+
},
17+
defaultVariants: {
18+
size: 'medium',
19+
},
20+
});
21+
22+
const colors = cva(null, {
23+
variants: {
24+
color: {
25+
0: styles.yellow,
26+
1: styles.blue,
27+
2: styles.pink,
28+
3: styles.cyan,
29+
4: styles.purple,
30+
},
31+
},
32+
});
33+
34+
interface AvatarVariants extends VariantProps<typeof avatar> {}
35+
36+
interface AvatarProps extends ImgHTMLAttributes<HTMLImageElement>, AvatarVariants {
37+
ref?: RefObject<HTMLImageElement | null>;
38+
}
39+
40+
interface InitialsAvatarProps extends SVGAttributes<SVGElement>, AvatarVariants {}
41+
42+
const Avatar = ({ className, children, size = 'medium', ref, src, ...props }: AvatarProps) => {
43+
const status = useImageLoadingStatus(src);
44+
45+
if (status !== 'loaded') {
46+
return <InitialsAvatar size={size}>{children}</InitialsAvatar>;
47+
}
48+
49+
// biome-ignore lint/a11y/useAltText: <explanation>
50+
return <img ref={ref} src={src} {...props} className={avatar({ size, className })} />;
51+
};
52+
53+
const InitialsAvatar = ({
54+
className,
55+
size = 'medium',
56+
children,
57+
...props
58+
}: InitialsAvatarProps) => {
59+
const color = children
60+
? (children.toString().charCodeAt(0) + children.toString().charCodeAt(1)) % 5
61+
: 0;
62+
return (
63+
// biome-ignore lint/a11y/noSvgWithoutTitle: <explanation>
64+
<svg
65+
role="img"
66+
className={cx(
67+
avatar({ size, className }),
68+
colors({ color: color as keyof typeof colors }),
69+
styles.initials,
70+
)}
71+
viewBox="0 0 24 24"
72+
{...props}
73+
>
74+
<text x="50%" y="50%" className={styles.text}>
75+
{children}
76+
</text>
77+
</svg>
78+
);
79+
};
80+
81+
export { Avatar, InitialsAvatar };
82+
export type { AvatarProps, InitialsAvatarProps };

packages/components/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import './styles/base.css';
22
import './styles/themes.css';
33

44
export type { AlertProps } from './Alert';
5+
export type { AvatarProps, InitialsAvatarProps } from './Avatar';
56
export type { BreadcrumbsProps, BreadcrumbProps } from './Breadcrumbs';
67
export type { ButtonProps } from './Button';
78
export type { ButtonGroupProps } from './ButtonGroup';
@@ -79,6 +80,7 @@ export type { ToolbarProps } from './Toolbar';
7980
export type { TooltipProps, TooltipTriggerProps } from './Tooltip';
8081

8182
export { Alert } from './Alert';
83+
export { Avatar, InitialsAvatar } from './Avatar';
8284
export { Breadcrumbs, Breadcrumb } from './Breadcrumbs';
8385
export { Button } from './Button';
8486
export { ButtonGroup } from './ButtonGroup';
@@ -159,4 +161,4 @@ export { ToggleButtonGroup } from './ToggleButtonGroup';
159161
export { ToggleIconButton } from './ToggleIconButton';
160162
export { Toolbar } from './Toolbar';
161163
export { Tooltip, TooltipTrigger } from './Tooltip';
162-
export { useHref, useMedia } from './utils';
164+
export { useHref, useImageLoadingStatus, useMedia } from './utils';
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
.base {
2+
background-color: var(--lp-color-bg-ui-primary);
3+
border-radius: 100%;
4+
}
5+
6+
.small {
7+
width: var(--lp-size-16);
8+
height: var(--lp-size-16);
9+
}
10+
11+
.medium {
12+
width: var(--lp-size-24);
13+
height: var(--lp-size-24);
14+
}
15+
16+
.large {
17+
width: var(--lp-size-40);
18+
height: var(--lp-size-40);
19+
}
20+
21+
.initials {
22+
font: var(--lp-text-label-1-semibold);
23+
font-size: var(--lp-font-size-100);
24+
}
25+
26+
.yellow {
27+
background-color: var(--lp-color-brand-yellow-dark);
28+
}
29+
30+
.blue {
31+
background-color: var(--lp-color-blue-600);
32+
}
33+
34+
.pink {
35+
background-color: var(--lp-color-brand-pink-base);
36+
}
37+
38+
.cyan {
39+
background-color: var(--lp-color-brand-cyan-base);
40+
}
41+
42+
.purple {
43+
background-color: var(--lp-color-purple-600);
44+
}
45+
46+
.text {
47+
fill: var(--lp-color-white-950);
48+
text-anchor: middle;
49+
dominant-baseline: central;
50+
}

packages/components/src/utils.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import type { Href } from '@react-types/shared';
22

3-
import { useEffect, useState } from 'react';
3+
import { useEffect, useLayoutEffect, useState } from 'react';
44
import { useHref as useRouterHref } from 'react-router';
55

6+
type ImageLoadingStatus = 'idle' | 'loading' | 'loaded' | 'error';
7+
68
const useMedia = (media: string) => {
79
const [isActive, setIsActive] = useState(false);
810

@@ -40,4 +42,34 @@ const useHref = (href: Href) => {
4042
return absoluteHref || routerHref;
4143
};
4244

43-
export { useHref, useMedia };
45+
const useImageLoadingStatus = (src?: string) => {
46+
const [loadingStatus, setLoadingStatus] = useState<ImageLoadingStatus>('idle');
47+
48+
useLayoutEffect(() => {
49+
if (!src) {
50+
setLoadingStatus('error');
51+
return;
52+
}
53+
54+
let isMounted = true;
55+
const image = new window.Image();
56+
57+
const updateStatus = (status: ImageLoadingStatus) => () => {
58+
if (!isMounted) return;
59+
setLoadingStatus(status);
60+
};
61+
62+
setLoadingStatus('loading');
63+
image.onload = updateStatus('loaded');
64+
image.onerror = updateStatus('error');
65+
image.src = src;
66+
67+
return () => {
68+
isMounted = false;
69+
};
70+
}, [src]);
71+
72+
return loadingStatus;
73+
};
74+
75+
export { useHref, useImageLoadingStatus, useMedia };
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { Meta, StoryObj } from '@storybook/react';
2+
import type { ComponentType } from 'react';
3+
4+
import { Box } from '@launchpad-ui/box';
5+
import { Avatar, InitialsAvatar } from '../src';
6+
7+
const meta: Meta<typeof Avatar> = {
8+
component: Avatar,
9+
subcomponents: { InitialsAvatar } as Record<string, ComponentType<unknown>>,
10+
title: 'Components/Content/Avatar',
11+
};
12+
13+
export default meta;
14+
15+
type Story = StoryObj<typeof Avatar>;
16+
17+
export const Example: Story = {
18+
args: {
19+
src: 'https://avatars.githubusercontent.com/u/2147624?v=4',
20+
alt: 'engineer',
21+
},
22+
};
23+
24+
export const Initials: Story = {
25+
render: (args) => (
26+
<InitialsAvatar size={args.size} aria-label="LD">
27+
LD
28+
</InitialsAvatar>
29+
),
30+
};
31+
32+
export const Sizes: Story = {
33+
render: (args) => (
34+
<Box display="flex" alignItems="flex-end" gap="$300">
35+
<Avatar size="small" {...args} />
36+
<Avatar size="medium" {...args} />
37+
<Avatar size="large" {...args} />
38+
</Box>
39+
),
40+
args: {
41+
src: 'https://avatars.githubusercontent.com/u/2147624?v=4',
42+
alt: 'engineer',
43+
},
44+
};

0 commit comments

Comments
 (0)