Skip to content

Commit

Permalink
feat(avatarGroup): introduce (#5916)
Browse files Browse the repository at this point in the history
* feat(avatarGroup): introduce

* update

* feat(avatarGroup): update base on feedback

* update

* re-add package

* update

* update

* update

* feat(avatarGroupe): update with feedBack

* fix: indentation

* remove `'use client';`

* feat(utils): separate "avatar"

* feat(avatarGroup): introduce

* update

* feat(avatarGroup): update base on feedback

* update

* re-add package

* update

* update

* update

* feat(avatarGroupe): update with feedBack

* fix: indentation

* remove `'use client';`

* feat(utils): separate "avatar"

* review: expedited code-review changes

* test(avatarGroup): add

* review: expedited code-review changes

* remove: removed files with wrong casing

* chore: renamed utils

* chore: fix linting order

* chore: fix tests

---------

Co-authored-by: Claudio Wunder <[email protected]>
  • Loading branch information
AugustinMauroy and ovflowd authored Oct 14, 2023
1 parent 5b0dc4b commit 4dc0089
Show file tree
Hide file tree
Showing 13 changed files with 288 additions and 0 deletions.
22 changes: 22 additions & 0 deletions components/Common/AvatarGroup/Avatar/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
.avatar {
@apply flex
h-8
w-8
items-center
justify-center
rounded-full
border-2
border-white
bg-neutral-100
object-cover
text-xs
text-neutral-800
dark:border-neutral-950
dark:bg-neutral-900
dark:text-neutral-300;
}

.avatarRoot {
@apply -ml-2
first:ml-0;
}
31 changes: 31 additions & 0 deletions components/Common/AvatarGroup/Avatar/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Meta as MetaObj, StoryObj } from '@storybook/react';

import { githubProfileAvatarUrl } from '@/util/gitHubUtils';

import Avatar from './';

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

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

export const NoSquare: Story = {
args: {
src: '/static/images/logos/stacked-dark.svg',
alt: 'SD',
},
};

export const FallBack: Story = {
args: {
src: 'https://avatars.githubusercontent.com/u/',
alt: 'UA',
},
};

export default { component: Avatar } as Meta;
20 changes: 20 additions & 0 deletions components/Common/AvatarGroup/Avatar/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import * as RadixAvatar from '@radix-ui/react-avatar';
import type { FC } from 'react';

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

type AvatarProps = {
src: string;
alt: string;
};

const Avatar: FC<AvatarProps> = ({ src, alt }) => (
<RadixAvatar.Root className={styles.avatarRoot}>
<RadixAvatar.Image src={src} alt={alt} className={styles.avatar} />
<RadixAvatar.Fallback delayMs={500} className={styles.avatar}>
{alt}
</RadixAvatar.Fallback>
</RadixAvatar.Root>
);

export default Avatar;
46 changes: 46 additions & 0 deletions components/Common/AvatarGroup/__tests__/index.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { render, fireEvent } from '@testing-library/react';

import { githubProfileAvatarUrl } from '@/util/gitHubUtils';

import AvatarGroup from '../index';

const names = [
'ovflowd',
'bmuenzenmeyer',
'AugustinMauroy',
'HinataKah0',
'Harkunwar',
'rodion-arr',
'mikeesto',
'bnb',
'benhalverson',
'aymen94',
'shanpriyan',
'Wai-Dung',
'manishprivet',
'araujogui',
];

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

describe('AvatarGroup component', () => {
it('renders the AvatarGroup component properly', () => {
const { getByText } = render(<AvatarGroup avatars={avatars} limit={2} />);

const showMoreButton = getByText('+12');
expect(showMoreButton).toBeInTheDocument();
});

it('displays the rest of the avatars when "show more" button is clicked', () => {
const { getByText } = render(<AvatarGroup avatars={avatars} limit={2} />);

const showMoreButton = getByText('+12');
fireEvent.click(showMoreButton);

const hideMoreButton = getByText('-12');
expect(hideMoreButton).toBeInTheDocument();
});
});
4 changes: 4 additions & 0 deletions components/Common/AvatarGroup/index.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.avatarGroup {
@apply flex
items-center;
}
61 changes: 61 additions & 0 deletions components/Common/AvatarGroup/index.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import type { Meta as MetaObj, StoryObj } from '@storybook/react';

import { githubProfileAvatarUrl } from '@/util/gitHubUtils';

import AvatarGroup from './';

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

const names = [
'ovflowd',
'bmuenzenmeyer',
'AugustinMauroy',
'HinataKah0',
'Harkunwar',
'rodion-arr',
'mikeesto',
'bnb',
'benhalverson',
'aymen94',
'shanpriyan',
'Wai-Dung',
'manishprivet',
'araujogui',
];

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

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

export const Default: Story = {
args: { ...defaultProps },
};

export const WithCustomLimit: Story = {
args: {
...defaultProps,
limit: 5,
},
};

export const InSmallContainer: Story = {
decorators: [
Story => (
<div className="w-[150px]">
<Story />
</div>
),
],
args: { ...defaultProps, limit: 5 },
};

export default { component: AvatarGroup } as Meta;
48 changes: 48 additions & 0 deletions components/Common/AvatarGroup/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import classNames from 'classnames';
import type { ComponentProps, FC } from 'react';
import { useState, useMemo } from 'react';

import { getAcronymFromString } from '@/util/stringUtils';

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

type AvatarGroupProps = {
avatars: ComponentProps<typeof Avatar>[];
limit?: number;
};

const AvatarGroup: FC<AvatarGroupProps> = ({ avatars, limit = 10 }) => {
const [showMore, setShowMore] = useState(false);

const renderAvatars = useMemo(
() => avatars.slice(0, showMore ? avatars.length : limit),
[showMore, avatars, limit]
);

return (
<div className={styles.avatarGroup}>
{renderAvatars.map((avatar, index) => (
<Avatar
src={avatar.src}
alt={getAcronymFromString(avatar.alt)}
key={index}
/>
))}

{avatars.length > limit && (
<span
onClick={() => setShowMore(!showMore)}
className={classNames(avatarstyles.avatarRoot, 'cursor-pointer')}
>
<span className={avatarstyles.avatar}>
{`${showMore ? '-' : '+'}${avatars.length - limit}`}
</span>
</span>
)}
</div>
);
};

export default AvatarGroup;
27 changes: 27 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"@heroicons/react": "~2.0.18",
"@mdx-js/react": "^2.3.0",
"@nodevu/core": "~0.1.0",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-toast": "^1.1.5",
"@types/node": "18.18.3",
Expand Down
9 changes: 9 additions & 0 deletions util/__tests__/gitHubUtils.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { githubProfileAvatarUrl } from '@/util/gitHubUtils';

describe('Github utils', () => {
it('githubProfileAvatarUrl returns the correct URL', () => {
expect(githubProfileAvatarUrl('octocat')).toBe(
'https://avatars.githubusercontent.com/octocat'
);
});
});
15 changes: 15 additions & 0 deletions util/__tests__/string.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { getAcronymFromString } from '@/util/stringUtils';

describe('String utils', () => {
it('getAcronymFromString returns the correct acronym', () => {
expect(getAcronymFromString('John Doe')).toBe('JD');
});

it('getAcronymFromString returns the correct acronym for a single word', () => {
expect(getAcronymFromString('John')).toBe('J');
});

it('getAcronymFromString if the string is empty, it returns NA', () => {
expect(getAcronymFromString('')).toBe('NA');
});
});
2 changes: 2 additions & 0 deletions util/gitHubUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const githubProfileAvatarUrl = (username: string): string =>
`https://avatars.githubusercontent.com/${username}`;
2 changes: 2 additions & 0 deletions util/stringUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const getAcronymFromString = (str: string) =>
[...(str.trim().match(/\b(\w)/g) || '')].join('').toUpperCase();

0 comments on commit 4dc0089

Please sign in to comment.