Skip to content

Commit bc3b2a9

Browse files
canerakdasovflowd
andauthored
feat: Introducing avatar tooltip (#7143)
* feat: tooltip component and avatar tooltip * chore: metabar story props updated * chore: self review * chore: self review * refactor: class names * refactor: horizontal margin added * feat: accessible avatars on mobile * feat: default author url * refactor: review updates * chore: self review * refactor: design and review updates * fix: Avatars in MetaBar story * refactor: review updates * fix: opening the tooltip portal within the dialog * fix: adjusting visible avatar count * refactor: review updates * Update apps/site/util/authorUtils.ts Co-authored-by: Claudio W <cwunder@gnome.org> Signed-off-by: Caner Akdas <canerakdas@gmail.com> * refactor: enhancing code readability * refactor: review update --------- Signed-off-by: Caner Akdas <canerakdas@gmail.com> Co-authored-by: Claudio W <cwunder@gnome.org>
1 parent 83081ef commit bc3b2a9

32 files changed

+845
-231
lines changed
Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
1-
.avatar {
2-
@apply flex
3-
size-8
1+
.item {
2+
@apply xs:max-h-10
3+
xs:max-w-10
4+
flex
5+
h-full
6+
max-h-12
7+
w-full
8+
max-w-12
49
items-center
510
justify-center
611
rounded-full
@@ -15,9 +20,26 @@
1520
dark:text-neutral-300;
1621
}
1722

18-
.avatarRoot {
19-
@apply -ml-2
20-
size-8
21-
flex-shrink-0
23+
.avatar {
24+
@apply size-8
25+
flex-shrink-0;
26+
27+
.wrapper {
28+
@apply max-xs:block
29+
max-xs:py-0;
30+
}
31+
}
32+
33+
.small {
34+
@apply xs:size-8
35+
xs:-ml-2
36+
ml-0.5
37+
size-10
38+
first:ml-0;
39+
}
40+
41+
.medium {
42+
@apply -ml-2.5
43+
size-10
2244
first:ml-0;
2345
}

apps/site/components/Common/AvatarGroup/Avatar/index.stories.tsx

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,23 @@ type Meta = MetaObj<typeof Avatar>;
88

99
export const Default: Story = {
1010
args: {
11-
src: getGitHubAvatarUrl('ovflowd'),
12-
alt: 'ovflowd',
11+
image: getGitHubAvatarUrl('ovflowd'),
12+
nickname: 'ovflowd',
1313
},
1414
};
1515

1616
export const NoSquare: Story = {
1717
args: {
18-
src: '/static/images/logos/nodejs.png',
19-
alt: 'SD',
18+
image: '/static/images/logo-hexagon-card.png',
19+
nickname: 'SD',
2020
},
2121
};
2222

2323
export const FallBack: Story = {
2424
args: {
25-
src: 'https://avatars.githubusercontent.com/u/',
26-
alt: 'UA',
25+
image: 'https://avatars.githubusercontent.com/u/',
26+
nickname: 'John Doe',
27+
fallback: 'JD',
2728
},
2829
};
2930

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,53 @@
11
import * as RadixAvatar from '@radix-ui/react-avatar';
2-
import type { FC } from 'react';
2+
import classNames from 'classnames';
3+
import type { ComponentPropsWithoutRef, ElementRef } from 'react';
4+
import { forwardRef } from 'react';
5+
6+
import Link from '@/components/Link';
37

48
import styles from './index.module.css';
59

610
export type AvatarProps = {
7-
src: string;
8-
alt: string;
9-
fallback: string;
11+
image?: string;
12+
name?: string;
13+
nickname: string;
14+
fallback?: string;
15+
size?: 'small' | 'medium';
16+
url?: string;
1017
};
1118

12-
const Avatar: FC<AvatarProps> = ({ src, alt, fallback }) => (
13-
<RadixAvatar.Root className={styles.avatarRoot}>
14-
<RadixAvatar.Image
15-
loading="lazy"
16-
src={src}
17-
alt={alt}
18-
title={alt}
19-
className={styles.avatar}
20-
/>
21-
<RadixAvatar.Fallback delayMs={500} className={styles.avatar}>
22-
{fallback}
23-
</RadixAvatar.Fallback>
24-
</RadixAvatar.Root>
25-
);
19+
const Avatar = forwardRef<
20+
ElementRef<typeof RadixAvatar.Root>,
21+
ComponentPropsWithoutRef<typeof RadixAvatar.Root> & AvatarProps
22+
>(({ image, nickname, name, fallback, url, size = 'small', ...props }, ref) => {
23+
const Wrapper = url ? Link : 'div';
24+
25+
return (
26+
<RadixAvatar.Root
27+
{...props}
28+
className={classNames(styles.avatar, styles[size], props.className)}
29+
ref={ref}
30+
>
31+
<Wrapper
32+
href={url || undefined}
33+
target={url ? '_blank' : undefined}
34+
className={styles.wrapper}
35+
>
36+
<RadixAvatar.Image
37+
loading="lazy"
38+
src={image}
39+
alt={name || nickname}
40+
className={styles.item}
41+
/>
42+
<RadixAvatar.Fallback
43+
delayMs={500}
44+
className={classNames(styles.item, styles[size])}
45+
>
46+
{fallback}
47+
</RadixAvatar.Fallback>
48+
</Wrapper>
49+
</RadixAvatar.Root>
50+
);
51+
});
2652

2753
export default Avatar;
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
.overlay {
2+
@apply flex
3+
min-w-56
4+
items-center
5+
gap-2
6+
p-3;
7+
}
8+
9+
.user {
10+
@apply grow;
11+
}
12+
13+
.name {
14+
@apply font-semibold
15+
text-neutral-900
16+
dark:text-neutral-300;
17+
}
18+
19+
.nickname {
20+
@apply font-medium
21+
text-neutral-700
22+
dark:text-neutral-500;
23+
}
24+
25+
.arrow {
26+
@apply w-3
27+
fill-neutral-600
28+
dark:fill-white;
29+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { Meta as MetaObj, StoryObj } from '@storybook/react';
2+
3+
import AvatarOverlay from '@/components/Common/AvatarGroup/Overlay';
4+
import { getAuthorWithId, getAuthorWithName } from '@/util/authorUtils';
5+
6+
type Story = StoryObj<typeof AvatarOverlay>;
7+
type Meta = MetaObj<typeof AvatarOverlay>;
8+
9+
export const Default: Story = {
10+
args: getAuthorWithId(['nodejs'], true)[0],
11+
};
12+
13+
export const FallBack: Story = {
14+
args: getAuthorWithName(['Node.js'], true)[0],
15+
};
16+
17+
export const WithoutName: Story = {
18+
args: getAuthorWithId(['canerakdas'], true)[0],
19+
};
20+
21+
export default { component: AvatarOverlay } as Meta;
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { ArrowUpRightIcon } from '@heroicons/react/24/solid';
2+
import type { ComponentProps, FC } from 'react';
3+
4+
import Avatar from '@/components/Common/AvatarGroup/Avatar';
5+
import Link from '@/components/Link';
6+
7+
import styles from './index.module.css';
8+
9+
export type AvatarOverlayProps = ComponentProps<typeof Avatar> & {
10+
url?: string;
11+
};
12+
13+
const AvatarOverlay: FC<AvatarOverlayProps> = ({
14+
image,
15+
name,
16+
nickname,
17+
fallback,
18+
url,
19+
}) => (
20+
<Link className={styles.overlay} href={url} target="_blank">
21+
<Avatar
22+
image={image}
23+
name={name}
24+
nickname={nickname}
25+
fallback={fallback}
26+
size="medium"
27+
/>
28+
<div className={styles.user}>
29+
{name && <div className={styles.name}>{name}</div>}
30+
{nickname && <div className={styles.nickname}>{nickname}</div>}
31+
</div>
32+
<ArrowUpRightIcon className={styles.arrow} />
33+
</Link>
34+
);
35+
36+
export default AvatarOverlay;

apps/site/components/Common/AvatarGroup/__tests__/index.test.mjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ const names = [
2222
];
2323

2424
const avatars = names.map(name => ({
25-
src: getGitHubAvatarUrl(name),
26-
alt: name,
25+
image: getGitHubAvatarUrl(name),
26+
nickname: name,
2727
}));
2828

2929
describe('AvatarGroup', () => {

apps/site/components/Common/AvatarGroup/index.stories.tsx

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Meta as MetaObj, StoryObj } from '@storybook/react';
22

33
import AvatarGroup from '@/components/Common/AvatarGroup';
4-
import { getGitHubAvatarUrl } from '@/util/gitHubUtils';
4+
import { getAuthorWithId } from '@/util/authorUtils';
55

66
type Story = StoryObj<typeof AvatarGroup>;
77
type Meta = MetaObj<typeof AvatarGroup>;
@@ -24,15 +24,13 @@ const names = [
2424
];
2525

2626
const unknownAvatar = {
27-
src: 'https://avatars.githubusercontent.com/u/',
28-
alt: 'unknown-avatar',
27+
image: 'https://avatars.githubusercontent.com/u/',
28+
nickname: 'unknown-avatar',
29+
fallback: 'UA',
2930
};
3031

3132
const defaultProps = {
32-
avatars: [
33-
unknownAvatar,
34-
...names.map(name => ({ src: getGitHubAvatarUrl(name), alt: name })),
35-
],
33+
avatars: [unknownAvatar, ...getAuthorWithId(names, true)],
3634
};
3735

3836
export const Default: Story = {

apps/site/components/Common/AvatarGroup/index.tsx

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,31 @@
11
'use client';
22

33
import classNames from 'classnames';
4-
import type { ComponentProps, FC } from 'react';
5-
import { useState, useMemo } from 'react';
4+
import type { FC } from 'react';
5+
import { useState, useMemo, Fragment } from 'react';
66

7+
import type { AvatarProps } from '@/components/Common/AvatarGroup/Avatar';
78
import Avatar from '@/components/Common/AvatarGroup/Avatar';
89
import avatarstyles from '@/components/Common/AvatarGroup/Avatar/index.module.css';
9-
import { getAcronymFromString } from '@/util/stringUtils';
10+
import AvatarOverlay from '@/components/Common/AvatarGroup/Overlay';
11+
import Tooltip from '@/components/Common/Tooltip';
1012

1113
import styles from './index.module.css';
1214

1315
type AvatarGroupProps = {
14-
avatars: Array<Omit<ComponentProps<typeof Avatar>, 'fallback'>>;
16+
avatars: Array<AvatarProps>;
1517
limit?: number;
1618
isExpandable?: boolean;
19+
size?: AvatarProps['size'];
20+
container?: HTMLElement;
1721
};
1822

1923
const AvatarGroup: FC<AvatarGroupProps> = ({
2024
avatars,
2125
limit = 10,
2226
isExpandable = true,
27+
size = 'small',
28+
container,
2329
}) => {
2430
const [showMore, setShowMore] = useState(false);
2531

@@ -30,21 +36,34 @@ const AvatarGroup: FC<AvatarGroupProps> = ({
3036

3137
return (
3238
<div className={styles.avatarGroup}>
33-
{renderAvatars.map((avatar, index) => (
34-
<Avatar
35-
src={avatar.src}
36-
alt={avatar.alt}
37-
fallback={getAcronymFromString(avatar.alt)}
38-
key={index}
39-
/>
39+
{renderAvatars.map(({ ...avatar }) => (
40+
<Fragment key={avatar.nickname}>
41+
<Tooltip
42+
asChild
43+
container={container}
44+
content={<AvatarOverlay {...avatar} />}
45+
>
46+
<Avatar
47+
{...avatar}
48+
size={size}
49+
className={classNames({
50+
'cursor-pointer': avatar.url,
51+
'pointer-events-none': !avatar.url,
52+
})}
53+
/>
54+
</Tooltip>
55+
</Fragment>
4056
))}
41-
4257
{avatars.length > limit && (
4358
<span
4459
onClick={isExpandable ? () => setShowMore(prev => !prev) : undefined}
45-
className={classNames(avatarstyles.avatarRoot, 'cursor-pointer')}
60+
className={classNames(
61+
avatarstyles.avatar,
62+
avatarstyles[size],
63+
'cursor-pointer'
64+
)}
4665
>
47-
<span className={avatarstyles.avatar}>
66+
<span className={avatarstyles.item}>
4867
{`${showMore ? '-' : '+'}${avatars.length - limit}`}
4968
</span>
5069
</span>

0 commit comments

Comments
 (0)