Skip to content

Commit

Permalink
feat: Introducing avatar tooltip (#7143)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>
Signed-off-by: Caner Akdas <[email protected]>

* refactor: enhancing code readability

* refactor: review update

---------

Signed-off-by: Caner Akdas <[email protected]>
Co-authored-by: Claudio W <[email protected]>
  • Loading branch information
2 people authored and bmuenzenmeyer committed Nov 21, 2024
1 parent 60194f8 commit 99a7249
Show file tree
Hide file tree
Showing 32 changed files with 845 additions and 231 deletions.
36 changes: 29 additions & 7 deletions apps/site/components/Common/AvatarGroup/Avatar/index.module.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
.avatar {
@apply flex
size-8
.item {
@apply xs:max-h-10
xs:max-w-10
flex
h-full
max-h-12
w-full
max-w-12
items-center
justify-center
rounded-full
Expand All @@ -15,9 +20,26 @@
dark:text-neutral-300;
}

.avatarRoot {
@apply -ml-2
size-8
flex-shrink-0
.avatar {
@apply size-8
flex-shrink-0;

.wrapper {
@apply max-xs:block
max-xs:py-0;
}
}

.small {
@apply xs:size-8
xs:-ml-2
ml-0.5
size-10
first:ml-0;
}

.medium {
@apply -ml-2.5
size-10
first:ml-0;
}
13 changes: 7 additions & 6 deletions apps/site/components/Common/AvatarGroup/Avatar/index.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,22 +8,23 @@ type Meta = MetaObj<typeof Avatar>;

export const Default: Story = {
args: {
src: getGitHubAvatarUrl('ovflowd'),
alt: 'ovflowd',
image: getGitHubAvatarUrl('ovflowd'),
nickname: 'ovflowd',
},
};

export const NoSquare: Story = {
args: {
src: '/static/images/logos/nodejs.png',
alt: 'SD',
image: '/static/images/logo-hexagon-card.png',
nickname: 'SD',
},
};

export const FallBack: Story = {
args: {
src: 'https://avatars.githubusercontent.com/u/',
alt: 'UA',
image: 'https://avatars.githubusercontent.com/u/',
nickname: 'John Doe',
fallback: 'JD',
},
};

Expand Down
62 changes: 44 additions & 18 deletions apps/site/components/Common/AvatarGroup/Avatar/index.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,53 @@
import * as RadixAvatar from '@radix-ui/react-avatar';
import type { FC } from 'react';
import classNames from 'classnames';
import type { ComponentPropsWithoutRef, ElementRef } from 'react';
import { forwardRef } from 'react';

import Link from '@/components/Link';

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

export type AvatarProps = {
src: string;
alt: string;
fallback: string;
image?: string;
name?: string;
nickname: string;
fallback?: string;
size?: 'small' | 'medium';
url?: string;
};

const Avatar: FC<AvatarProps> = ({ src, alt, fallback }) => (
<RadixAvatar.Root className={styles.avatarRoot}>
<RadixAvatar.Image
loading="lazy"
src={src}
alt={alt}
title={alt}
className={styles.avatar}
/>
<RadixAvatar.Fallback delayMs={500} className={styles.avatar}>
{fallback}
</RadixAvatar.Fallback>
</RadixAvatar.Root>
);
const Avatar = forwardRef<
ElementRef<typeof RadixAvatar.Root>,
ComponentPropsWithoutRef<typeof RadixAvatar.Root> & AvatarProps
>(({ image, nickname, name, fallback, url, size = 'small', ...props }, ref) => {
const Wrapper = url ? Link : 'div';

return (
<RadixAvatar.Root
{...props}
className={classNames(styles.avatar, styles[size], props.className)}
ref={ref}
>
<Wrapper
href={url || undefined}
target={url ? '_blank' : undefined}
className={styles.wrapper}
>
<RadixAvatar.Image
loading="lazy"
src={image}
alt={name || nickname}
className={styles.item}
/>
<RadixAvatar.Fallback
delayMs={500}
className={classNames(styles.item, styles[size])}
>
{fallback}
</RadixAvatar.Fallback>
</Wrapper>
</RadixAvatar.Root>
);
});

export default Avatar;
29 changes: 29 additions & 0 deletions apps/site/components/Common/AvatarGroup/Overlay/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
.overlay {
@apply flex
min-w-56
items-center
gap-2
p-3;
}

.user {
@apply grow;
}

.name {
@apply font-semibold
text-neutral-900
dark:text-neutral-300;
}

.nickname {
@apply font-medium
text-neutral-700
dark:text-neutral-500;
}

.arrow {
@apply w-3
fill-neutral-600
dark:fill-white;
}
21 changes: 21 additions & 0 deletions apps/site/components/Common/AvatarGroup/Overlay/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { Meta as MetaObj, StoryObj } from '@storybook/react';

import AvatarOverlay from '@/components/Common/AvatarGroup/Overlay';
import { getAuthorWithId, getAuthorWithName } from '@/util/authorUtils';

type Story = StoryObj<typeof AvatarOverlay>;
type Meta = MetaObj<typeof AvatarOverlay>;

export const Default: Story = {
args: getAuthorWithId(['nodejs'], true)[0],
};

export const FallBack: Story = {
args: getAuthorWithName(['Node.js'], true)[0],
};

export const WithoutName: Story = {
args: getAuthorWithId(['canerakdas'], true)[0],
};

export default { component: AvatarOverlay } as Meta;
36 changes: 36 additions & 0 deletions apps/site/components/Common/AvatarGroup/Overlay/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { ArrowUpRightIcon } from '@heroicons/react/24/solid';
import type { ComponentProps, FC } from 'react';

import Avatar from '@/components/Common/AvatarGroup/Avatar';
import Link from '@/components/Link';

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

export type AvatarOverlayProps = ComponentProps<typeof Avatar> & {
url?: string;
};

const AvatarOverlay: FC<AvatarOverlayProps> = ({
image,
name,
nickname,
fallback,
url,
}) => (
<Link className={styles.overlay} href={url} target="_blank">
<Avatar
image={image}
name={name}
nickname={nickname}
fallback={fallback}
size="medium"
/>
<div className={styles.user}>
{name && <div className={styles.name}>{name}</div>}
{nickname && <div className={styles.nickname}>{nickname}</div>}
</div>
<ArrowUpRightIcon className={styles.arrow} />
</Link>
);

export default AvatarOverlay;
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ const names = [
];

const avatars = names.map(name => ({
src: getGitHubAvatarUrl(name),
alt: name,
image: getGitHubAvatarUrl(name),
nickname: name,
}));

describe('AvatarGroup', () => {
Expand Down
12 changes: 5 additions & 7 deletions apps/site/components/Common/AvatarGroup/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Meta as MetaObj, StoryObj } from '@storybook/react';

import AvatarGroup from '@/components/Common/AvatarGroup';
import { getGitHubAvatarUrl } from '@/util/gitHubUtils';
import { getAuthorWithId } from '@/util/authorUtils';

type Story = StoryObj<typeof AvatarGroup>;
type Meta = MetaObj<typeof AvatarGroup>;
Expand All @@ -24,15 +24,13 @@ const names = [
];

const unknownAvatar = {
src: 'https://avatars.githubusercontent.com/u/',
alt: 'unknown-avatar',
image: 'https://avatars.githubusercontent.com/u/',
nickname: 'unknown-avatar',
fallback: 'UA',
};

const defaultProps = {
avatars: [
unknownAvatar,
...names.map(name => ({ src: getGitHubAvatarUrl(name), alt: name })),
],
avatars: [unknownAvatar, ...getAuthorWithId(names, true)],
};

export const Default: Story = {
Expand Down
47 changes: 33 additions & 14 deletions apps/site/components/Common/AvatarGroup/index.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,31 @@
'use client';

import classNames from 'classnames';
import type { ComponentProps, FC } from 'react';
import { useState, useMemo } from 'react';
import type { FC } from 'react';
import { useState, useMemo, Fragment } from 'react';

import type { AvatarProps } from '@/components/Common/AvatarGroup/Avatar';
import Avatar from '@/components/Common/AvatarGroup/Avatar';
import avatarstyles from '@/components/Common/AvatarGroup/Avatar/index.module.css';
import { getAcronymFromString } from '@/util/stringUtils';
import AvatarOverlay from '@/components/Common/AvatarGroup/Overlay';
import Tooltip from '@/components/Common/Tooltip';

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

type AvatarGroupProps = {
avatars: Array<Omit<ComponentProps<typeof Avatar>, 'fallback'>>;
avatars: Array<AvatarProps>;
limit?: number;
isExpandable?: boolean;
size?: AvatarProps['size'];
container?: HTMLElement;
};

const AvatarGroup: FC<AvatarGroupProps> = ({
avatars,
limit = 10,
isExpandable = true,
size = 'small',
container,
}) => {
const [showMore, setShowMore] = useState(false);

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

return (
<div className={styles.avatarGroup}>
{renderAvatars.map((avatar, index) => (
<Avatar
src={avatar.src}
alt={avatar.alt}
fallback={getAcronymFromString(avatar.alt)}
key={index}
/>
{renderAvatars.map(({ ...avatar }) => (
<Fragment key={avatar.nickname}>
<Tooltip
asChild
container={container}
content={<AvatarOverlay {...avatar} />}
>
<Avatar
{...avatar}
size={size}
className={classNames({
'cursor-pointer': avatar.url,
'pointer-events-none': !avatar.url,
})}
/>
</Tooltip>
</Fragment>
))}

{avatars.length > limit && (
<span
onClick={isExpandable ? () => setShowMore(prev => !prev) : undefined}
className={classNames(avatarstyles.avatarRoot, 'cursor-pointer')}
className={classNames(
avatarstyles.avatar,
avatarstyles[size],
'cursor-pointer'
)}
>
<span className={avatarstyles.avatar}>
<span className={avatarstyles.item}>
{`${showMore ? '-' : '+'}${avatars.length - limit}`}
</span>
</span>
Expand Down
Loading

0 comments on commit 99a7249

Please sign in to comment.