Skip to content

Commit

Permalink
Feature: APP-2710 - Implement Avatar component (#63)
Browse files Browse the repository at this point in the history
  • Loading branch information
Fabricevladimir authored Jan 22, 2024
1 parent 5f38ab1 commit fbde791
Show file tree
Hide file tree
Showing 8 changed files with 225 additions and 1 deletion.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),

### Added

- Implement `InputDate` component
- Implement `InputDate` and `Avatar` components

## [1.0.8] - 2024-01-17

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
],
"author": "Aragon Association",
"dependencies": {
"@radix-ui/react-avatar": "^1.0.0",
"@radix-ui/react-progress": "^1.0.0",
"@radix-ui/react-switch": "^1.0.0",
"@radix-ui/react-toggle-group": "^1.0.0",
Expand Down
48 changes: 48 additions & 0 deletions src/components/avatars/avatar/avatar.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Avatar } from './avatar';

const meta: Meta<typeof Avatar> = {
title: 'components/Avatars/Avatar',
component: Avatar,
tags: ['autodocs'],
parameters: {
design: {
type: 'figma',
url: 'https://www.figma.com/file/ISSDryshtEpB7SUSdNqAcw/branch/jfKRr1V9evJUp1uBeyP3Zz/Aragon-ODS?node-id=11953%3A12188&mode=dev',
},
},
};

type Story = StoryObj<typeof Avatar>;

/**
* Default usage example of the Avatar component.
*/
export const Default: Story = {
args: {
src: '/icons/person.svg',
size: 'sm',
},
};

/**
* Usage of the Avatar component with the default fallback.
*/
export const DefaultFallback: Story = {
args: {
responsiveSize: { sm: 'md' },
},
};

/**
* Avatar component with a custom fallback
*/
export const CustomFallback: Story = {
args: {
src: 'broken-image',
size: 'lg',
fallback: <span className="flex size-full items-center justify-center bg-primary-400 text-neutral-0">SO</span>,
},
};

export default meta;
71 changes: 71 additions & 0 deletions src/components/avatars/avatar/avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { render, screen } from '@testing-library/react';
import { Avatar, type IAvatarProps } from './avatar';

describe('<Avatar /> component', () => {
const createTestComponent = (props?: Partial<IAvatarProps>) => {
const completeProps: IAvatarProps = { ...props };

return <Avatar {...completeProps} />;
};

// need to mock global Image as radix-ui only renders
// an <img/> once it has been loaded therefore the usual
// img events can't be fired with testing-library.
const originalGlobalImage = global.Image;

beforeAll(() => {
(window.Image as unknown) = class MockImage {
onload: () => void = () => {};
src: string = '';
constructor() {
setTimeout(() => {
this.onload();
}, 100);
}
};
});

afterAll(() => {
global.Image = originalGlobalImage;
});

it('renders fallback when no image provided', () => {
const fallbackContent = 'fallback content';
render(createTestComponent({ fallback: fallbackContent }));

const fallback = screen.getByText(fallbackContent);

expect(fallback).toBeInTheDocument();
expect(screen.queryByRole('img')).not.toBeInTheDocument();
});

it('does not render fallback when valid image provided', async () => {
const fallbackContent = 'fallback content';
render(createTestComponent({ fallback: fallbackContent, src: 'img.jpg' }));

expect(screen.queryByText(fallbackContent)).not.toBeInTheDocument();
});

it('renders loading animation while image is loading', async () => {
render(createTestComponent({ src: 'img.jpg' }));

const fallback = screen.getByTestId('fallback');
expect(fallback).toHaveClass('animate-pulse');
});

it('renders the image with provided alt text after it has loaded', async () => {
const altText = 'test';
render(createTestComponent({ alt: altText, src: 'img.jpg' }));

const image = await screen.findByRole('img');
expect(image).toBeInTheDocument();
expect(image).toHaveAttribute('alt', altText);
});

it('renders the image with a default alt text', async () => {
render(createTestComponent({ src: 'img.jpg' }));

const image = await screen.findByRole('img');
expect(image).toHaveAttribute('alt');
});
});
91 changes: 91 additions & 0 deletions src/components/avatars/avatar/avatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import * as RadixAvatar from '@radix-ui/react-avatar';
import classNames from 'classnames';
import type React from 'react';
import { useState, type ComponentPropsWithoutRef, type ReactNode } from 'react';
import { type ResponsiveAttribute, type ResponsiveAttributeClassMap } from '../../../types';
import { responsiveUtils } from '../../../utils';

type AvatarSize = 'sm' | 'md' | 'lg';

export interface IAvatarProps extends ComponentPropsWithoutRef<'img'> {
/**
* Fallback content to display when the image fails to load or
* no image is provided.
*/
fallback?: ReactNode;
/**
* Responsive size attribute for the avatar.
*/
responsiveSize?: ResponsiveAttribute<AvatarSize>;
/**
* The size of the avatar.
* @default sm
*/
size?: AvatarSize;
}

const responsiveSizeClasses: ResponsiveAttributeClassMap<AvatarSize> = {
sm: {
sm: 'w-6 h-6',
md: 'md:w-6 md:h-6',
lg: 'lg:w-6 lg:h-6',
xl: 'xl:w-6 xl:h-6',
'2xl': '2xl:w-6 2xl:h-6',
},
md: {
sm: 'w-10 h-10',
md: 'md:w-10 md:h-10',
lg: 'lg:w-10 lg:h-10',
xl: 'xl:w-10 xl:h-10',
'2xl': '2xl:w-10 2xl:h-10',
},
lg: {
sm: 'w-16 h-16',
md: 'md:w-16 md:h-16',
lg: 'lg:w-16 lg:h-16',
xl: 'xl:w-16 xl:h-16',
'2xl': '2xl:w-16 2xl:h-16',
},
};

/**
* Avatar component
*/
export const Avatar: React.FC<IAvatarProps> = (props) => {
const { alt = 'avatar', className, fallback, responsiveSize = {}, size = 'sm', ...imageProps } = props;

const containerClassNames = classNames(
'flex items-center justify-center overflow-hidden rounded-full',
responsiveUtils.generateClassNames(size, responsiveSize, responsiveSizeClasses),
className,
);

const [imgLoading, setImgLoading] = useState(true);

const handleOnLoadingStatusChange = (status: RadixAvatar.ImageLoadingStatus) => {
setImgLoading(status === 'loading');
};

const showFallback = !!fallback && !imgLoading;
return (
<RadixAvatar.Root className={containerClassNames}>
<RadixAvatar.Image
alt={alt}
{...imageProps}
className="size-full rounded-[inherit] object-cover"
onLoadingStatusChange={handleOnLoadingStatusChange}
/>
<RadixAvatar.Fallback
data-testid="fallback"
className={classNames(
'size-full rounded-[inherit]',
{ 'animate-pulse bg-neutral-200': imgLoading },
{ 'bg-neutral-200': !fallback },
{ 'flex items-center justify-center': showFallback },
)}
>
{showFallback && fallback}
</RadixAvatar.Fallback>
</RadixAvatar.Root>
);
};
1 change: 1 addition & 0 deletions src/components/avatars/avatar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './avatar';
1 change: 1 addition & 0 deletions src/components/avatars/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './avatar';
export * from './avatarIcon';
11 changes: 11 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1676,6 +1676,17 @@
"@babel/runtime" "^7.13.10"
"@radix-ui/react-primitive" "1.0.3"

"@radix-ui/react-avatar@^1.0.0":
version "1.0.4"
resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.0.4.tgz#de9a5349d9e3de7bbe990334c4d2011acbbb9623"
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"

"@radix-ui/[email protected]":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"
Expand Down

0 comments on commit fbde791

Please sign in to comment.