From bd0a2e131131a95d74ebe87fea17ab8bf5a5161b Mon Sep 17 00:00:00 2001 From: PavloButynets <123271613+PavloButynets@users.noreply.github.com> Date: Wed, 4 Dec 2024 15:18:21 +0200 Subject: [PATCH] Create a UserAvatar component (#2898) * Create a UserAvatar component, style it in scss, add it to stirybook, write tests for it * added tests * fixed sonar issues, made changes according to comments to PR --- .../components/user-avatar/UserAvatar.scss | 63 +++++++ .../components/user-avatar/UserAvatar.tsx | 106 +++++++++++ .../stories/UserAvatar.stories.tsx | 168 ++++++++++++++++++ .../user-avatar/UserAvatar.spec.jsx | 72 ++++++++ 4 files changed, 409 insertions(+) create mode 100644 src/design-system/components/user-avatar/UserAvatar.scss create mode 100644 src/design-system/components/user-avatar/UserAvatar.tsx create mode 100644 src/design-system/stories/UserAvatar.stories.tsx create mode 100644 tests/unit/design-system/components/user-avatar/UserAvatar.spec.jsx diff --git a/src/design-system/components/user-avatar/UserAvatar.scss b/src/design-system/components/user-avatar/UserAvatar.scss new file mode 100644 index 000000000..2c049c3ce --- /dev/null +++ b/src/design-system/components/user-avatar/UserAvatar.scss @@ -0,0 +1,63 @@ +@use '~scss/utilities' as *; + +.#{$prefix}user-avatar { + --#{$prefix}avatar-size-sm: #{get-var('line-height-3xl')}; + --#{$prefix}avatar-size-md: #{get-var('line-height-5xl')}; + --#{$prefix}avatar-size-lg: #{get-var('line-height-6xl')}; + + display: flex; + align-items: center; + justify-content: center; + position: relative; + + .#{$prefix}avatar { + font-family: get-var('font-family-rubik'); + font-weight: get-var('font-weight-semibold'); + background-color: get-var('blue-gray-200'); + color: get-var('blue-gray-800'); + + &-sm { + width: var(--#{$prefix}avatar-size-sm); + height: var(--#{$prefix}avatar-size-sm); + font-size: get-var('font-size-md'); + } + + &-md { + width: var(--#{$prefix}avatar-size-md); + height: var(--#{$prefix}avatar-size-md); + font-size: get-var('font-size-md'); + } + + &-lg { + width: var(--#{$prefix}avatar-size-lg); + height: var(--#{$prefix}avatar-size-lg); + font-size: get-var('font-size-lg'); + } + } + + .#{$prefix}user-avatar-status { + position: absolute; + border-radius: 50%; + background-color: get-var('green-500'); + right: 0; + bottom: 0; + + &-sm { + border: get-var('border-width-md') solid get-var('neutral-0'); + width: calc(var(--#{$prefix}avatar-size-sm) * 0.2); + height: calc(var(--#{$prefix}avatar-size-sm) * 0.2); + } + + &-md { + border: get-var('border-width-lg') solid get-var('neutral-0'); + width: calc(var(--#{$prefix}avatar-size-md) * 0.2); + height: calc(var(--#{$prefix}avatar-size-md) * 0.2); + } + + &-lg { + border: get-var('border-width-lg') solid get-var('neutral-0'); + width: calc(var(--#{$prefix}avatar-size-lg) * 0.2); + height: calc(var(--#{$prefix}avatar-size-lg) * 0.2); + } + } +} diff --git a/src/design-system/components/user-avatar/UserAvatar.tsx b/src/design-system/components/user-avatar/UserAvatar.tsx new file mode 100644 index 000000000..db6c09c1c --- /dev/null +++ b/src/design-system/components/user-avatar/UserAvatar.tsx @@ -0,0 +1,106 @@ +import '~scss-components/user-avatar/UserAvatar.scss' +import { forwardRef } from 'react' +import { + Avatar as MuiAvatar, + AvatarProps as MuiAvatarProps +} from '@mui/material' +import CheckIcon from '@mui/icons-material/Check' +import { cn } from '~/utils/cn' + +const variants = ['check', 'avatar', 'monogram', 'photo'] as const +const sizes = ['sm', 'md', 'lg'] as const + +interface BaseUserAvatarProps { + variant?: (typeof variants)[number] + src?: string + size?: (typeof sizes)[number] + firstName: string + lastName: string + isOnline?: boolean +} + +export type UserAvatarProps = BaseUserAvatarProps & + Omit + +type Ref = MuiAvatarProps['ref'] +const UserAvatar = forwardRef( + ( + { + variant = 'avatar', + src, + size = 'sm', + firstName, + lastName, + isOnline, + onClick, + ...props + }: UserAvatarProps, + forwardedRef: Ref + ) => { + const monogram = firstName.charAt(0) + lastName.charAt(0) + + const avatarClass = cn('s2s-avatar', `s2s-avatar-${size}`) + + let avatarContent + if (variant === 'photo' && src) { + avatarContent = ( + + ) + } else if (variant === 'monogram') { + avatarContent = ( + + {monogram} + + ) + } else if (variant === 'check') { + avatarContent = ( + + + + ) + } else { + avatarContent = ( + + ) + } + + return ( +
+ {avatarContent} + {isOnline && ( + + )} +
+ ) + } +) + +UserAvatar.displayName = 'UserAvatar' +export default UserAvatar diff --git a/src/design-system/stories/UserAvatar.stories.tsx b/src/design-system/stories/UserAvatar.stories.tsx new file mode 100644 index 000000000..93c4ec77a --- /dev/null +++ b/src/design-system/stories/UserAvatar.stories.tsx @@ -0,0 +1,168 @@ +import type { Meta, StoryObj } from '@storybook/react' +import { fn } from '@storybook/test' +import UserAvatar from '~scss-components/user-avatar/UserAvatar' + +const meta: Meta = { + title: 'Components/UserAvatar', + component: UserAvatar, + parameters: { + layout: 'centered', + docs: { + description: { + component: ` +The \`UserAvatar\` component is a flexible and customizable avatar element that can be used to display user profile images, initials, and online status. It supports different variants, sizes, and states, making it a versatile component for showcasing user information across your application. + +#### Key Features: +- **Variants**: Choose from several types of avatars: + - Photo: Displays a user’s profile image (requires src prop). + - Monogram: Shows the initials of the user based on the firstName and lastName props. + - Check: Displays a check icon inside the avatar. + - Avatar: Displays the user’s initials by default, making it ideal for generic avatars when no photo is available. +- **Sizes:** Adjust the avatar's size to fit your design needs, with options for sm (small), md (medium), or lg (large). +- **Online Status:** Show the user's online status with a small color-coded status dot that appears when isOnline is set to true. +- **Customizable:** Use the sx prop to apply custom styles or override the default styling to match your design requirements. +- **Monogram Generation:** Automatically generates a monogram based on the user’s first and last name, perfect when no image is available. + +This component is ideal for user profile representations, whether in a user list, comments section, or anywhere you need to display user information. + ` + } + } + }, + tags: ['autodocs'], + argTypes: { + variant: { + control: { type: 'select' }, + options: ['check', 'avatar', 'monogram', 'photo'], + description: 'The type of avatar to display.', + table: { + type: { summary: 'check | avatar | monogram | photo' } + } + }, + size: { + control: { type: 'text' }, + description: 'The size of the avatar.', + table: { + type: { summary: 'sm | md | lg' } + } + }, + isOnline: { + control: { type: 'boolean' }, + description: 'Displays online status if true.', + table: { + type: { summary: 'boolean' } + } + }, + src: { + control: { type: 'text' }, + description: 'The URL of the image for the photo variant.', + table: { + type: { summary: 'string' } + } + }, + firstName: { + control: { type: 'text' }, + description: 'The first name of the user.', + table: { + type: { summary: 'string' } + } + }, + lastName: { + control: { type: 'text' }, + description: 'The last name of the user.', + table: { + type: { summary: 'string' } + } + } + }, + args: { + variant: 'avatar', + size: 'sm', + firstName: 'Test', + lastName: 'User', + isOnline: false, + onClick: fn() + } +} + +export default meta +type Story = StoryObj + +export const All: Story = { + render: (args) => ( +
+ + + + +
+ ), + parameters: { + docs: { + description: { + story: + 'This story showcases all avatar variants in a single row for easy comparison.' + } + } + } +} + +export const PhotoVariant: Story = { + parameters: { + docs: { + description: { + story: + 'Displays the photo variant of the UserAvatar component. Use this variant when displaying a user profile picture.' + } + } + }, + args: { + variant: 'photo', + src: '/src/assets/img/user-profile-page/avatar.png' + } +} + +export const MonogramVariant: Story = { + parameters: { + docs: { + description: { + story: + 'Displays the monogram variant of the UserAvatar component. The user’s initials are shown as a fallback when no photo is provided.' + } + } + }, + args: { + variant: 'monogram' + } +} + +export const CheckVariant: Story = { + parameters: { + docs: { + description: { + story: + 'Displays the check variant of the UserAvatar component. This variant includes a check icon, typically used for selection or confirmation purposes.' + } + } + }, + args: { + variant: 'check' + } +} + +export const AvatarVariant: Story = { + parameters: { + docs: { + description: { + story: + 'Displays the default avatar variant of the UserAvatar component. The initials of the user are displayed by default, with optional online status.' + } + } + }, + args: { + variant: 'avatar' + } +} diff --git a/tests/unit/design-system/components/user-avatar/UserAvatar.spec.jsx b/tests/unit/design-system/components/user-avatar/UserAvatar.spec.jsx new file mode 100644 index 000000000..99211ef08 --- /dev/null +++ b/tests/unit/design-system/components/user-avatar/UserAvatar.spec.jsx @@ -0,0 +1,72 @@ +import { render, screen } from '@testing-library/react'; +import UserAvatar from '~scss-components/user-avatar/UserAvatar'; + +const firstName = 'John'; +const lastName = 'Doe'; +const avatarSrc = '/src/assets/img/user-profile-page/avatar.png'; +const isOnline = true; + +describe('UserAvatar Component', () => { + it('should render with monogram variant', () => { + render( + + ); + + const monogramElement = screen.getByText(firstName.charAt(0) + lastName.charAt(0)); + expect(monogramElement).toBeInTheDocument(); + }); + + it('should show online status indicator when isOnline is true', () => { + render( + + ); + + const onlineStatus = document.querySelector('.s2s-user-avatar-status'); + expect(onlineStatus).toBeInTheDocument(); + }); + + it('should not show online status indicator when isOnline is false', () => { + render( + + ); + + const onlineStatus = document.querySelector('.s2s-user-avatar-status'); + expect(onlineStatus).not.toBeInTheDocument(); + }); + + it('should trigger onClick callback when clicked', () => { + const handleClick = vi.fn(); + + render( + + ); + + const avatarElement = document.querySelector('.s2s-avatar'); + avatarElement.click(); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + +});