diff --git a/components/Common/AvatarGroup/Avatar/index.module.css b/components/Common/AvatarGroup/Avatar/index.module.css new file mode 100644 index 0000000000000..3fa1ddf0f8c5e --- /dev/null +++ b/components/Common/AvatarGroup/Avatar/index.module.css @@ -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; +} diff --git a/components/Common/AvatarGroup/Avatar/index.stories.tsx b/components/Common/AvatarGroup/Avatar/index.stories.tsx new file mode 100644 index 0000000000000..ef5432bf3f4b6 --- /dev/null +++ b/components/Common/AvatarGroup/Avatar/index.stories.tsx @@ -0,0 +1,31 @@ +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +import { githubProfileAvatarUrl } from '@/util/gitHubUtils'; + +import Avatar from './'; + +type Story = StoryObj; +type Meta = MetaObj; + +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; diff --git a/components/Common/AvatarGroup/Avatar/index.tsx b/components/Common/AvatarGroup/Avatar/index.tsx new file mode 100644 index 0000000000000..b1c31945adbc7 --- /dev/null +++ b/components/Common/AvatarGroup/Avatar/index.tsx @@ -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 = ({ src, alt }) => ( + + + + {alt} + + +); + +export default Avatar; diff --git a/components/Common/AvatarGroup/__tests__/index.test.mjs b/components/Common/AvatarGroup/__tests__/index.test.mjs new file mode 100644 index 0000000000000..203f192dbcee2 --- /dev/null +++ b/components/Common/AvatarGroup/__tests__/index.test.mjs @@ -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(); + + const showMoreButton = getByText('+12'); + expect(showMoreButton).toBeInTheDocument(); + }); + + it('displays the rest of the avatars when "show more" button is clicked', () => { + const { getByText } = render(); + + const showMoreButton = getByText('+12'); + fireEvent.click(showMoreButton); + + const hideMoreButton = getByText('-12'); + expect(hideMoreButton).toBeInTheDocument(); + }); +}); diff --git a/components/Common/AvatarGroup/index.module.css b/components/Common/AvatarGroup/index.module.css new file mode 100644 index 0000000000000..b0934bf25cb30 --- /dev/null +++ b/components/Common/AvatarGroup/index.module.css @@ -0,0 +1,4 @@ +.avatarGroup { + @apply flex + items-center; +} diff --git a/components/Common/AvatarGroup/index.stories.tsx b/components/Common/AvatarGroup/index.stories.tsx new file mode 100644 index 0000000000000..9b3022e65391a --- /dev/null +++ b/components/Common/AvatarGroup/index.stories.tsx @@ -0,0 +1,61 @@ +import type { Meta as MetaObj, StoryObj } from '@storybook/react'; + +import { githubProfileAvatarUrl } from '@/util/gitHubUtils'; + +import AvatarGroup from './'; + +type Story = StoryObj; +type Meta = MetaObj; + +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 => ( +
+ +
+ ), + ], + args: { ...defaultProps, limit: 5 }, +}; + +export default { component: AvatarGroup } as Meta; diff --git a/components/Common/AvatarGroup/index.tsx b/components/Common/AvatarGroup/index.tsx new file mode 100644 index 0000000000000..76af32e001f96 --- /dev/null +++ b/components/Common/AvatarGroup/index.tsx @@ -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[]; + limit?: number; +}; + +const AvatarGroup: FC = ({ avatars, limit = 10 }) => { + const [showMore, setShowMore] = useState(false); + + const renderAvatars = useMemo( + () => avatars.slice(0, showMore ? avatars.length : limit), + [showMore, avatars, limit] + ); + + return ( +
+ {renderAvatars.map((avatar, index) => ( + + ))} + + {avatars.length > limit && ( + setShowMore(!showMore)} + className={classNames(avatarstyles.avatarRoot, 'cursor-pointer')} + > + + {`${showMore ? '-' : '+'}${avatars.length - limit}`} + + + )} +
+ ); +}; + +export default AvatarGroup; diff --git a/package-lock.json b/package-lock.json index 5512aeaf758f9..de99378e69ff3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,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", @@ -4515,6 +4516,32 @@ } } }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.0.4.tgz", + "integrity": "sha512-kVK2K7ZD3wwj3qhle0ElXhOjbezIgyl2hVvgwfIdexL3rN6zJmy5AqqIf+D31lxVppdzV8CjAfZ6PklkmInZLw==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", diff --git a/package.json b/package.json index f0cfd148a4a81..25d3e1941e21f 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/util/__tests__/gitHubUtils.mjs b/util/__tests__/gitHubUtils.mjs new file mode 100644 index 0000000000000..be3941ad9542b --- /dev/null +++ b/util/__tests__/gitHubUtils.mjs @@ -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' + ); + }); +}); diff --git a/util/__tests__/string.mjs b/util/__tests__/string.mjs new file mode 100644 index 0000000000000..f5c5be9229615 --- /dev/null +++ b/util/__tests__/string.mjs @@ -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'); + }); +}); diff --git a/util/gitHubUtils.ts b/util/gitHubUtils.ts new file mode 100644 index 0000000000000..79472e203f585 --- /dev/null +++ b/util/gitHubUtils.ts @@ -0,0 +1,2 @@ +export const githubProfileAvatarUrl = (username: string): string => + `https://avatars.githubusercontent.com/${username}`; diff --git a/util/stringUtils.ts b/util/stringUtils.ts new file mode 100644 index 0000000000000..5c34d24abf53a --- /dev/null +++ b/util/stringUtils.ts @@ -0,0 +1,2 @@ +export const getAcronymFromString = (str: string) => + [...(str.trim().match(/\b(\w)/g) || '')].join('').toUpperCase();