Skip to content

Commit

Permalink
feat: adding avatar base component to design system react (#390)
Browse files Browse the repository at this point in the history
## **Description**

This PR adds the initial `AvatarBase` component to design system react. 

Key features:
1. Five size variants: xs (16px), sm (24px), md (32px), lg (40px), and
xl (48px)
2. Support for different content types: text, images, and icons
3. Customizable styling through className prop using Tailwind classes
4. Built-in accessibility features through Radix UI's Slot primitive
5. Comprehensive test coverage and Storybook documentation

## **Related issues**

Fixes: #359 

## **Manual testing steps**

1. Run Storybook locally (`yarn storybook`)
2. Navigate to React Components/AvatarBase
3. Check the docs align with component and make sense
4. Verify controls work

## **Screenshots/Recordings**

### **Before**
N/A - New component

### **After**




https://github.com/user-attachments/assets/51ef4b5c-8f41-4f67-909d-cc6033a8acaf




## **Pre-merge author checklist**

- [x] I've followed MetaMask Contributor Docs
- [x] I've completed the PR template to the best of my ability
- [x] I've included tests (unit tests and Storybook stories)
- [x] I've documented my code using JSDoc format
- [ ] I've applied the right labels on the PR

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
  • Loading branch information
georgewrmarshall authored Feb 4, 2025
1 parent eadea1f commit 4812f88
Show file tree
Hide file tree
Showing 7 changed files with 597 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { AvatarBaseSize } from './AvatarBase.types';
import { TextVariant } from '../text';

export const AVATAR_BASE_SIZE_CLASS_MAP: Record<AvatarBaseSize, string> = {
[AvatarBaseSize.Xs]: 'h-4 w-4',
[AvatarBaseSize.Sm]: 'h-6 w-6',
[AvatarBaseSize.Md]: 'h-8 w-8',
[AvatarBaseSize.Lg]: 'h-10 w-10',
[AvatarBaseSize.Xl]: 'h-12 w-12',
};

export const AVATAR_BASE_SIZE_TO_TEXT_VARIANT_MAP: Record<
AvatarBaseSize,
TextVariant
> = {
[AvatarBaseSize.Xs]: TextVariant.BodyXs,
[AvatarBaseSize.Sm]: TextVariant.BodyXs,
[AvatarBaseSize.Md]: TextVariant.BodySm,
[AvatarBaseSize.Lg]: TextVariant.BodyMd,
[AvatarBaseSize.Xl]: TextVariant.BodyMd,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import type { Meta, StoryObj } from '@storybook/react';
import React from 'react';

import { Icon, IconName, IconSize, Text, TextVariant, TextColor } from '..';
import { AvatarBase } from './AvatarBase';
import { AvatarBaseSize, AvatarBaseShape } from './AvatarBase.types';
import README from './README.mdx';

const meta: Meta<typeof AvatarBase> = {
title: 'React Components/AvatarBase',
component: AvatarBase,
parameters: {
docs: {
page: README,
},
},
argTypes: {
children: {
control: 'text',
description:
'Optional prop for the content to be rendered within the AvatarBase. Not required if fallbackText is provided',
},
fallbackText: {
control: 'text',
description: 'Optional text to display when no children are provided',
},
fallbackTextProps: {
control: 'object',
description:
'Optional props to be passed to the Text component when rendering fallback text',
},
className: {
control: 'text',
description:
'Optional prop for additional CSS classes to be applied to the AvatarBase component',
},
size: {
control: 'select',
options: Object.keys(AvatarBaseSize),
mapping: AvatarBaseSize,
description: 'Optional prop to control the size of the AvatarBase',
},
shape: {
control: 'select',
options: Object.keys(AvatarBaseShape),
mapping: AvatarBaseShape,
description: 'Optional prop to control the shape of the AvatarBase',
},
},
};

export default meta;
type Story = StoryObj<typeof AvatarBase>;

export const Default: Story = {
args: {
fallbackText: 'A',
},
};

export const Shape: Story = {
render: () => (
<div className="flex gap-2 items-center">
<AvatarBase shape={AvatarBaseShape.Circle} fallbackText="C" />
<AvatarBase shape={AvatarBaseShape.Square} fallbackText="S" />
</div>
),
};

export const Size: Story = {
render: () => (
<div className="flex flex-col gap-2">
<div className="flex gap-2 items-center">
<AvatarBase size={AvatarBaseSize.Xs} fallbackText="XS" />
<AvatarBase size={AvatarBaseSize.Sm} fallbackText="SM" />
<AvatarBase size={AvatarBaseSize.Md} fallbackText="MD" />
<AvatarBase size={AvatarBaseSize.Lg} fallbackText="LG" />
<AvatarBase size={AvatarBaseSize.Xl} fallbackText="XL" />
</div>
<div className="flex gap-2 items-center">
<AvatarBase
shape={AvatarBaseShape.Square}
size={AvatarBaseSize.Xs}
fallbackText="XS"
/>
<AvatarBase
shape={AvatarBaseShape.Square}
size={AvatarBaseSize.Sm}
fallbackText="SM"
/>
<AvatarBase
shape={AvatarBaseShape.Square}
size={AvatarBaseSize.Md}
fallbackText="MD"
/>
<AvatarBase
shape={AvatarBaseShape.Square}
size={AvatarBaseSize.Lg}
fallbackText="LG"
/>
<AvatarBase
shape={AvatarBaseShape.Square}
size={AvatarBaseSize.Xl}
fallbackText="XL"
/>
</div>
</div>
),
};

export const FallbackText: Story = {
render: () => (
<div className="flex gap-2">
<AvatarBase fallbackText="A" />
<AvatarBase fallbackText="B" />
<AvatarBase fallbackText="C" />
</div>
),
};

export const FallbackTextWithProps: Story = {
render: () => (
<div className="flex gap-2">
<AvatarBase
fallbackText="A"
fallbackTextProps={{ color: TextColor.PrimaryDefault }}
/>
<AvatarBase
fallbackText="B"
fallbackTextProps={{ color: TextColor.ErrorDefault }}
/>
<AvatarBase
fallbackText="C"
fallbackTextProps={{ color: TextColor.SuccessDefault }}
/>
</div>
),
};

export const Children: Story = {
render: () => (
<div className="flex gap-2 items-center">
{/* Text */}
<AvatarBase fallbackText="A" />
{/* Image */}
<AvatarBase>
<img
src="https://cryptologos.cc/logos/avalanche-avax-logo.png?v=040"
alt="Avalanche"
className="w-full h-full object-cover"
/>
</AvatarBase>
{/* Icon */}
<AvatarBase>
<Icon name={IconName.User} size={IconSize.Sm} />
</AvatarBase>
</div>
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { render, screen } from '@testing-library/react';
import React from 'react';

import { TextColor } from '../text';
import { AvatarBase } from './AvatarBase';
import { AVATAR_BASE_SIZE_CLASS_MAP } from './AvatarBase.constants';
import { AvatarBaseSize, AvatarBaseShape } from './AvatarBase.types';

describe('AvatarBase', () => {
it('renders with default styles', () => {
render(<AvatarBase fallbackText="A" data-testid="avatar" />);

const avatar = screen.getByTestId('avatar');
expect(screen.getByText('A')).toBeInTheDocument();
});

it('applies size classes correctly', () => {
const { rerender } = render(
<AvatarBase
size={AvatarBaseSize.Xs}
fallbackText="A"
data-testid="avatar"
/>,
);

Object.entries(AVATAR_BASE_SIZE_CLASS_MAP).forEach(([size, classes]) => {
rerender(
<AvatarBase
size={size as AvatarBaseSize}
fallbackText="A"
data-testid="avatar"
/>,
);
const avatar = screen.getByTestId('avatar');
const classArray = classes.split(' ');
classArray.forEach((className) => {
expect(avatar).toHaveClass(className);
});
});
});

it('renders children correctly', () => {
render(
<AvatarBase>
<img src="test.jpg" alt="test" data-testid="avatar-image" />
</AvatarBase>,
);

expect(screen.getByTestId('avatar-image')).toBeInTheDocument();
});

it('merges custom className with default classes', () => {
render(
<AvatarBase
className="custom-class"
fallbackText="A"
data-testid="avatar"
/>,
);

const avatar = screen.getByTestId('avatar');
expect(avatar).toHaveClass('custom-class');
expect(avatar).toHaveClass('rounded-full');
});

it('renders as child component when asChild is true', () => {
render(
<AvatarBase asChild>
<span>A</span>
</AvatarBase>,
);

const avatar = screen.getByText('A');
expect(avatar.tagName).toBe('SPAN');
});

it('applies custom styles when provided', () => {
render(
<AvatarBase
style={{ backgroundColor: 'red' }}
fallbackText="A"
data-testid="avatar"
/>,
);

const avatar = screen.getByTestId('avatar');
expect(avatar).toHaveStyle({ backgroundColor: 'red' });
});

it('applies correct shape classes', () => {
const { rerender } = render(
<AvatarBase
shape={AvatarBaseShape.Circle}
fallbackText="A"
data-testid="avatar"
/>,
);

let avatar = screen.getByTestId('avatar');
expect(avatar).toHaveClass('rounded-full');

rerender(
<AvatarBase
shape={AvatarBaseShape.Square}
fallbackText="A"
data-testid="avatar"
/>,
);
avatar = screen.getByTestId('avatar');
expect(avatar).toHaveClass('rounded-lg');
});

it('uses circle shape by default', () => {
render(<AvatarBase fallbackText="A" data-testid="avatar" />);

const avatar = screen.getByTestId('avatar');
expect(avatar).toHaveClass('rounded-full');
});

it('renders fallbackText when no children are provided', () => {
render(<AvatarBase fallbackText="Test" />);
expect(screen.getByText('Test')).toBeInTheDocument();
});

it('prioritizes children over fallbackText', () => {
render(
<AvatarBase fallbackText="Fallback">
<span>Child</span>
</AvatarBase>,
);
expect(screen.getByText('Child')).toBeInTheDocument();
expect(screen.queryByText('Fallback')).not.toBeInTheDocument();
});

it('applies fallbackTextProps correctly', () => {
render(
<AvatarBase
fallbackText="Test"
fallbackTextProps={{
color: TextColor.PrimaryDefault,
'data-testid': 'fallback-text',
}}
/>,
);
const fallbackText = screen.getByTestId('fallback-text');
expect(fallbackText).toHaveClass('text-primary-default');
});

it('uses correct text variant based on size', () => {
const { rerender } = render(
<AvatarBase
size={AvatarBaseSize.Xs}
fallbackText="A"
fallbackTextProps={{ 'data-testid': 'fallback-text' }}
/>,
);

// Test XS size
let fallbackText = screen.getByTestId('fallback-text');
expect(fallbackText).toHaveClass('text-s-body-xs');

// Test MD size
rerender(
<AvatarBase
size={AvatarBaseSize.Md}
fallbackText="A"
fallbackTextProps={{ 'data-testid': 'fallback-text' }}
/>,
);
fallbackText = screen.getByTestId('fallback-text');
expect(fallbackText).toHaveClass('text-s-body-sm');

// Test XL size
rerender(
<AvatarBase
size={AvatarBaseSize.Xl}
fallbackText="A"
fallbackTextProps={{ 'data-testid': 'fallback-text' }}
/>,
);
fallbackText = screen.getByTestId('fallback-text');
expect(fallbackText).toHaveClass('text-s-body-md');
});
});
Loading

0 comments on commit 4812f88

Please sign in to comment.