Skip to content

Commit

Permalink
feat: adding avatar icon to design system react
Browse files Browse the repository at this point in the history
  • Loading branch information
georgewrmarshall committed Feb 5, 2025
1 parent 2c5a62b commit c93ba9e
Show file tree
Hide file tree
Showing 8 changed files with 480 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { IconSize, IconColor } from '../icon';
import { AvatarIconSize } from './AvatarIcon.types';
import { AvatarIconSeverity } from './AvatarIcon.types';
import { AvatarBaseSize } from '../avatar-base';

export const AVATAR_ICON_SIZE_TO_ICON_SIZE_CLASSNAME_MAP: Record<
AvatarIconSize,
IconSize
> = {
[AvatarIconSize.Xs]: IconSize.Xs, // 16px avatar -> 12px icon
[AvatarIconSize.Sm]: IconSize.Sm, // 24px avatar -> 16px icon
[AvatarIconSize.Md]: IconSize.Md, // 32px avatar -> 20px icon
[AvatarIconSize.Lg]: IconSize.Lg, // 40px avatar -> 24px icon
[AvatarIconSize.Xl]: IconSize.Xl, // 48px avatar -> 32px icon
};

export const AVATAR_ICON_SEVERITY_CLASSNAME_MAP: Record<
AvatarIconSeverity,
{ background: string; iconColor: IconColor }
> = {
[AvatarIconSeverity.Default]: {
background: 'bg-background-muted',
iconColor: IconColor.IconAlternative,
},
[AvatarIconSeverity.Info]: {
background: 'bg-info-muted',
iconColor: IconColor.InfoDefault,
},
[AvatarIconSeverity.Success]: {
background: 'bg-success-muted',
iconColor: IconColor.SuccessDefault,
},
[AvatarIconSeverity.Error]: {
background: 'bg-error-muted',
iconColor: IconColor.ErrorDefault,
},
[AvatarIconSeverity.Warning]: {
background: 'bg-warning-muted',
iconColor: IconColor.WarningDefault,
},
};

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

import { AvatarIcon } from './AvatarIcon';
import { AvatarIconSize, AvatarIconSeverity } from '.';
import { IconName } from '../icon';
import README from './README.mdx';

const meta: Meta<typeof AvatarIcon> = {
title: 'React Components/AvatarIcon',
component: AvatarIcon,
parameters: {
docs: {
page: README,
},
},
argTypes: {
iconName: {
control: 'select',
options: Object.values(IconName),
description: 'Required icon name from the icon set',
},
iconProps: {
control: 'object',
description:
'Optional props to be passed to the Icon component. The size prop will be automatically mapped from AvatarIconSize.',
},
size: {
control: 'select',
options: Object.keys(AvatarIconSize),
mapping: AvatarIconSize,
description:
'Optional prop to control the size of the avatar. Defaults to AvatarIconSize.Md',
},
severity: {
control: 'select',
options: Object.keys(AvatarIconSeverity),
mapping: AvatarIconSeverity,
description:
'Optional prop to control the severity of the avatar. Defaults to AvatarIconSeverity.Default',
},
className: {
control: 'text',
description:
'Optional additional CSS classes to be applied to the component',
},
},
};

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

export const Default: Story = {
args: {
iconName: IconName.Arrow2UpRight,
},
};

export const IconNameStory: Story = {
render: () => (
<div className="flex gap-2">
<AvatarIcon iconName={IconName.Arrow2UpRight} />
<AvatarIcon iconName={IconName.User} />
<AvatarIcon iconName={IconName.Setting} />
<AvatarIcon iconName={IconName.Search} />
</div>
),
name: 'IconName',
};

export const Size: Story = {
render: () => (
<div className="flex gap-2 items-center">
<AvatarIcon iconName={IconName.User} size={AvatarIconSize.Xs} />
<AvatarIcon iconName={IconName.User} size={AvatarIconSize.Sm} />
<AvatarIcon iconName={IconName.User} size={AvatarIconSize.Md} />
<AvatarIcon iconName={IconName.User} size={AvatarIconSize.Lg} />
<AvatarIcon iconName={IconName.User} size={AvatarIconSize.Xl} />
</div>
),
};

export const Severity: Story = {
render: () => (
<div className="flex gap-2">
<AvatarIcon
iconName={IconName.User}
severity={AvatarIconSeverity.Default}
/>
<AvatarIcon iconName={IconName.Info} severity={AvatarIconSeverity.Info} />
<AvatarIcon
iconName={IconName.Check}
severity={AvatarIconSeverity.Success}
/>
<AvatarIcon
iconName={IconName.Warning}
severity={AvatarIconSeverity.Warning}
/>
<AvatarIcon
iconName={IconName.Danger}
severity={AvatarIconSeverity.Error}
/>
</div>
),
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { render, screen } from '@testing-library/react';
import React from 'react';

import { IconName } from '../icon';
import { AvatarIcon } from './AvatarIcon';
import {
AVATAR_ICON_SEVERITY_CLASSNAME_MAP,
AVATAR_ICON_SIZE_TO_ICON_SIZE_CLASSNAME_MAP,
} from './AvatarIcon.constants';
import { AvatarIconSeverity } from './AvatarIcon.types';
import { AvatarIconSize } from '.';
import { ICON_SIZE_CLASS_MAP } from '../icon/Icon.constants';

describe('AvatarIcon', () => {
it('renders with default props', () => {
render(
<AvatarIcon iconName={IconName.AddSquare} data-testid="avatar-icon" />,
);
const avatarIcon = screen.getByTestId('avatar-icon');
expect(avatarIcon).toBeInTheDocument();
expect(avatarIcon).toHaveClass(
AVATAR_ICON_SEVERITY_CLASSNAME_MAP[AvatarIconSeverity.Default].background,
);
});

it('renders icon correctly', () => {
render(
<AvatarIcon
iconName={IconName.AddSquare}
iconProps={{ 'data-testid': 'icon' }}
/>,
);
const icon = screen.getByTestId('icon');
expect(icon).toBeInTheDocument();
});

describe('Sizes', () => {
Object.values(AvatarIconSize).forEach((size) => {
it(`applies ${size} size correctly`, () => {
render(
<AvatarIcon
iconName={IconName.AddSquare}
size={size}
iconProps={{ 'data-testid': 'icon' }}
/>,
);
const icon = screen.getByTestId('icon');
expect(icon).toHaveClass(ICON_SIZE_CLASS_MAP[size]);
});
});
});

describe('Severities', () => {
Object.values(AvatarIconSeverity).forEach((severity) => {
it(`applies ${severity} severity correctly`, () => {
render(
<AvatarIcon
iconName={IconName.AddSquare}
severity={severity}
data-testid="avatar-icon"
iconProps={{ 'data-testid': 'icon' }}
/>,
);
const avatarIcon = screen.getByTestId('avatar-icon');
const icon = screen.getByTestId('icon');
expect(avatarIcon).toHaveClass(
AVATAR_ICON_SEVERITY_CLASSNAME_MAP[severity].background,
);
expect(icon).toHaveClass(
AVATAR_ICON_SEVERITY_CLASSNAME_MAP[severity].iconColor,
);
});
});
});

it('applies custom className', () => {
render(
<AvatarIcon
iconName={IconName.AddSquare}
className="custom-class"
data-testid="avatar-icon"
/>,
);
const avatarIcon = screen.getByTestId('avatar-icon');
expect(avatarIcon).toHaveClass('custom-class');
});

it('applies custom icon props', () => {
render(
<AvatarIcon
iconName={IconName.AddSquare}
iconProps={{
className: 'custom-icon-class',
'data-testid': 'icon',
}}
/>,
);
const icon = screen.getByTestId('icon');
expect(icon).toHaveClass('custom-icon-class');
});

it('forwards ref to AvatarBase', () => {
const ref = React.createRef<HTMLDivElement>();
render(<AvatarIcon ref={ref} iconName={IconName.AddSquare} />);
expect(ref.current).toBeInstanceOf(HTMLDivElement);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';

import { AvatarBase, AvatarBaseShape } from '../avatar-base';
import { Icon } from '../icon';
import type { AvatarIconProps } from './AvatarIcon.types';
import {
AVATAR_ICON_SIZE_TO_ICON_SIZE_CLASSNAME_MAP,
AVATAR_ICON_SEVERITY_CLASSNAME_MAP,
AVATAR_ICON_TO_AVATAR_BASE_SIZE_MAP,
} from './AvatarIcon.constants';
import { AvatarIconSeverity } from './AvatarIcon.types';
import { AvatarIconSize } from './AvatarIcon.types';
import { twMerge } from '../../utils/tw-merge';

export const AvatarIcon = React.forwardRef<HTMLDivElement, AvatarIconProps>(
(
{
iconName,
iconProps,
size = AvatarIconSize.Md,
severity = AvatarIconSeverity.Default,
className,
...props
},
ref,
) => {
const baseSize = AVATAR_ICON_TO_AVATAR_BASE_SIZE_MAP[size];

return (
<AvatarBase
ref={ref}
shape={AvatarBaseShape.Circle}
size={baseSize}
className={twMerge(
AVATAR_ICON_SEVERITY_CLASSNAME_MAP[severity].background,
className,
)}
{...props}
>
{iconName && (
<Icon
name={iconName}
size={AVATAR_ICON_SIZE_TO_ICON_SIZE_CLASSNAME_MAP[size]}
color={AVATAR_ICON_SEVERITY_CLASSNAME_MAP[severity].iconColor}
{...iconProps}
/>
)}
</AvatarBase>
);
},
);

AvatarIcon.displayName = 'AvatarIcon';
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { ComponentProps } from 'react';

import { IconName } from '../icon';
import type { IconProps } from '../icon';

export enum AvatarIconSeverity {
Default = 'default',
Info = 'info',
Success = 'success',
Error = 'error',
Warning = 'warning',
}

export enum AvatarIconSize {
/**
* Extra small size (16px)
*/
Xs = 'xs',
/**
* Small size (24px)
*/
Sm = 'sm',
/**
* Medium size (32px)
*/
Md = 'md',
/**
* Large size (40px)
*/
Lg = 'lg',
/**
* Extra large size (48px)
*/
Xl = 'xl',
}

export type AvatarIconProps = Omit<
ComponentProps<'div'>,
'children' | 'size'
> & {
/**
* Required icon name from the icon set
*/
iconName: IconName;
/**
* Optional props to be passed to the Icon component
*/
iconProps?: Omit<IconProps, 'name'>;
/**
* Optional prop to control the size of the avatar
* @default AvatarIconSize.Md
*/
size?: AvatarIconSize;
/**
* Optional prop to control the severity of the avatar
* @default AvatarIconSeverity.Default
*/
severity?: AvatarIconSeverity;
/**
* Optional additional CSS classes to be applied to the component
*/
className?: string;
};
Loading

0 comments on commit c93ba9e

Please sign in to comment.