Skip to content

Commit

Permalink
feat(avatar): can add icon inside avatar (#1281)
Browse files Browse the repository at this point in the history
* feat(icon): add icon capability

* chore: prettier formatting

* feat(icon): add icon prop, icon sizing func, icon to storybook

* test(avatar): add tests for icon addition

* test: enforce icon only being paste icon

* fix: some minor fixes

* test: completed icon in avatar tests

* chore: add icon dependency

* chore: fix eslint errors

* chore(icon): minifiy icon-list file

* chore(icon): un-minify icon-list file

* chore: fix ts lint errors in test file

* chore(avatar): adjust & add tests for utils function

* test: add more edge case testing for icon prop

* test: replace removed snapshot tests

* chore: refactor content variant return and add tests

* chore: add DOM tests, removed redundant props

* chore: update style-props dependency

* chore: update icon dependency

* chore: fix linting errors

* chore: refactor renderAvatarContents to ReactFC AvatarComponents

* chore: added changeset for avatar/icon

* chore: change to yarn lock

Co-authored-by: Glorili Alejandro <galejandro@twilio.com>
Co-authored-by: Nora Krantz <nkrantz@twilio.com>
Co-authored-by: TheSisb <shadiisber@gmail.com>
  • Loading branch information
4 people authored Mar 25, 2021
1 parent 6d3ef1d commit 3419ff6
Show file tree
Hide file tree
Showing 8 changed files with 241 additions and 63 deletions.
6 changes: 6 additions & 0 deletions .changeset/spotty-parrots-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@twilio-paste/avatar': minor
'@twilio-paste/core': minor
---

Created an 'icon' prop on Avatar so that users can display Paste icons inside of Avatar components.
76 changes: 75 additions & 1 deletion packages/paste-core/components/avatar/__tests__/avatar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import * as React from 'react';
import {render, screen} from '@testing-library/react';
import {UserIcon} from '@twilio-paste/icons/esm/UserIcon';
import {Box} from '@twilio-paste/box';
// @ts-ignore typescript doesn't like js imports
import axe from '../../../../../.jest/axe-helper';
import {Avatar} from '../src';
import {
getCorrespondingLineHeightFromSizeToken,
getCorrespondingFontSizeFromSizeToken,
getCorrespondingIconSizeFromSizeToken,
getComputedTokenNames,
getInitialsFromName,
} from '../src/utils';
Expand Down Expand Up @@ -34,6 +37,12 @@ describe('Avatar', () => {
expect(getCorrespondingLineHeightFromSizeToken('sizeIcon100')).toEqual('lineHeight100');
expect(getCorrespondingLineHeightFromSizeToken('sizeIcon110')).toEqual('lineHeight110');
});
it('should throw an error when non IconSize values are passed in', () => {
// @ts-expect-error
expect(() => getCorrespondingLineHeightFromSizeToken('size50')).toThrow();
// @ts-expect-error
expect(() => getCorrespondingLineHeightFromSizeToken(true)).toThrow();
});
});

describe('getCorrespondingFontSizeFromSizeToken', () => {
Expand All @@ -50,16 +59,49 @@ describe('Avatar', () => {
expect(getCorrespondingFontSizeFromSizeToken('sizeIcon100')).toEqual('fontSize60');
expect(getCorrespondingFontSizeFromSizeToken('sizeIcon110')).toEqual('fontSize70');
});
it('should throw an error when non IconSize values are passed in', () => {
// @ts-expect-error
expect(() => getCorrespondingFontSizeFromSizeToken('size50')).toThrow();
// @ts-expect-error
expect(() => getCorrespondingFontSizeFromSizeToken(true)).toThrow();
});
});

describe('getCorrespondingIconSizeFromSizeToken', () => {
it('should return a reduced sizeIcon to match icon size', () => {
expect(getCorrespondingIconSizeFromSizeToken('sizeIcon10')).toEqual('sizeIcon10');
expect(getCorrespondingIconSizeFromSizeToken('sizeIcon20')).toEqual('sizeIcon10');
expect(getCorrespondingIconSizeFromSizeToken('sizeIcon30')).toEqual('sizeIcon10');
expect(getCorrespondingIconSizeFromSizeToken('sizeIcon40')).toEqual('sizeIcon10');
expect(getCorrespondingIconSizeFromSizeToken('sizeIcon50')).toEqual('sizeIcon20');
expect(getCorrespondingIconSizeFromSizeToken('sizeIcon60')).toEqual('sizeIcon20');
expect(getCorrespondingIconSizeFromSizeToken('sizeIcon70')).toEqual('sizeIcon30');
expect(getCorrespondingIconSizeFromSizeToken('sizeIcon80')).toEqual('sizeIcon40');
expect(getCorrespondingIconSizeFromSizeToken('sizeIcon90')).toEqual('sizeIcon50');
expect(getCorrespondingIconSizeFromSizeToken('sizeIcon100')).toEqual('sizeIcon70');
expect(getCorrespondingIconSizeFromSizeToken('sizeIcon110')).toEqual('sizeIcon80');
});
it('should throw an error when non IconSize values are passed in', () => {
// @ts-expect-error
expect(() => getCorrespondingIconSizeFromSizeToken('size50')).toThrow();
// @ts-expect-error
expect(() => getCorrespondingIconSizeFromSizeToken(true)).toThrow();
});
});

describe('getComputedTokenNames', () => {
it('should handle single size values', () => {
expect(getComputedTokenNames('sizeIcon50')).toEqual({fontSize: 'fontSize10', lineHeight: 'lineHeight50'});
expect(getComputedTokenNames('sizeIcon50')).toEqual({
fontSize: 'fontSize10',
lineHeight: 'lineHeight50',
iconSize: 'sizeIcon20',
});
});
it('should handle responsive size values', () => {
expect(getComputedTokenNames(['sizeIcon50', 'sizeIcon100'])).toEqual({
fontSize: ['fontSize10', 'fontSize60'],
lineHeight: ['lineHeight50', 'lineHeight100'],
iconSize: ['sizeIcon20', 'sizeIcon70'],
});
});
});
Expand All @@ -85,12 +127,44 @@ describe('Avatar', () => {
});
});

describe('ensure icon is a Paste Icon', () => {
it('should fail if icon is not a Paste Icon', () => {
// @ts-expect-error
expect(() => render(<Avatar size="sizeIcon20" name="avatar example" icon="UserIcon" />)).toThrow();
// @ts-expect-error
// eslint-disable-next-line react/jsx-boolean-value
expect(() => render(<Avatar size="sizeIcon20" name="avatar example" icon={true} />)).toThrow();
// @ts-expect-error
expect(() => render(<Avatar size="sizeIcon20" name="avatar example" icon={<Box />} />)).toThrow();
});
});

describe('Render an icon and its attributes', () => {
it('should render an svg', () => {
render(<Avatar data-testid="avatar" size="sizeIcon20" name="avatar example" icon={UserIcon} />);
const avatarComponent = screen.getByTestId('avatar');
expect(avatarComponent.querySelectorAll('svg').length).toEqual(1);
});
it('should not have an aria-hidden attribute set to true', () => {
render(<Avatar data-testid="avatar" size="sizeIcon20" name="avatar example" icon={UserIcon} />);
const avatarComponent = screen.getByTestId('avatar');
expect(avatarComponent.getAttribute('aria-hidden')).not.toEqual(true);
});
it('should have a title equal to name attribute of Avatar', () => {
const name = 'avatar example';
render(<Avatar data-testid="avatar" size="sizeIcon20" name={name} icon={UserIcon} />);
const avatarComponent = screen.getByTestId('avatar');
expect(avatarComponent.querySelector('title')).toHaveTextContent(name);
});
});

describe('accessibility', () => {
it('should have no accessibility violations', async () => {
const {container} = render(
<>
<Avatar size="sizeIcon10" name="Simon Taggart" />
<Avatar size="sizeIcon10" name="avatar example" src="/avatars/avatar2.png" />
<Avatar size="sizeIcon10" name="Simon Taggart" icon={UserIcon} />
</>
);
const results = await axe(container);
Expand Down
2 changes: 2 additions & 0 deletions packages/paste-core/components/avatar/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"peerDependencies": {
"@twilio-paste/box": "4.0.2",
"@twilio-paste/design-tokens": "6.6.0",
"@twilio-paste/icons": "5.1.1",
"@twilio-paste/style-props": "3.0.1",
"@twilio-paste/styling-library": "0.3.1",
"@twilio-paste/text": "4.0.1",
Expand All @@ -39,6 +40,7 @@
"devDependencies": {
"@twilio-paste/box": "4.0.2",
"@twilio-paste/design-tokens": "6.6.0",
"@twilio-paste/icons": "5.1.1",
"@twilio-paste/style-props": "3.0.1",
"@twilio-paste/styling-library": "0.3.1",
"@twilio-paste/text": "4.0.1",
Expand Down
73 changes: 39 additions & 34 deletions packages/paste-core/components/avatar/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,47 @@ import * as React from 'react';
import * as PropTypes from 'prop-types';
import {Text} from '@twilio-paste/text';
import {Box, safelySpreadBoxProps} from '@twilio-paste/box';
import {IconSize, isIconSizeTokenProp} from '@twilio-paste/style-props';
import {isIconSizeTokenProp} from '@twilio-paste/style-props';
import {getComputedTokenNames, getInitialsFromName} from './utils';
import type {AvatarProps} from './types';

const AvatarContents: React.FC<AvatarProps> = ({name, size, src, icon: Icon}) => {
const computedTokenNames = getComputedTokenNames(size);
if (Icon != null) {
if (typeof Icon !== 'function' || typeof Icon.displayName !== 'string' || !Icon.displayName.includes('Icon')) {
throw new Error('[Paste Avatar]: icon prop expected to be a Paste icon only.');
}
return (
<Box maxWidth="100%" size={size} display="flex" alignItems="center" justifyContent="center">
<Icon decorative={false} title={name} size={computedTokenNames.iconSize} color="colorText" />
</Box>
);
}
if (src != null) {
return <Box as="img" alt={name} maxWidth="100%" src={src} size={size} title={name} />;
}
return (
<Text
as="abbr"
display="block"
fontSize={computedTokenNames.fontSize}
fontWeight="fontWeightBold"
lineHeight={computedTokenNames.lineHeight}
textAlign="center"
textDecoration="none"
title={name}
>
{getInitialsFromName(name)}
</Text>
);
};

export interface AvatarProps extends React.HTMLAttributes<'div'> {
name: string;
size: IconSize;
src?: string;
}
const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
({name, children, size = 'sizeIcon70', src, ...props}, ref) => {
const computedTokenNames = getComputedTokenNames(size);
if (src != null && name === undefined) {
console.error('Paste Avatar: You must provide a name if you are displaying an image');
({name, children, size = 'sizeIcon70', src, icon, ...props}, ref) => {
if (name === undefined) {
console.error('[Paste Avatar]: name prop is required');
}

return (
<Box
{...safelySpreadBoxProps(props)}
Expand All @@ -26,30 +53,7 @@ const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
ref={ref}
size={size}
>
{src != null ? (
<Box
{...safelySpreadBoxProps(props)}
as="img"
alt={name}
maxWidth="100%"
src={src}
size={size}
title={name}
/>
) : (
<Text
as="abbr"
display="block"
fontSize={computedTokenNames.fontSize}
fontWeight="fontWeightBold"
lineHeight={computedTokenNames.lineHeight}
textAlign="center"
textDecoration="none"
title={name}
>
{getInitialsFromName(name)}
</Text>
)}
<AvatarContents name={name} size={size} src={src} icon={icon} {...props} />
</Box>
);
}
Expand All @@ -60,6 +64,7 @@ Avatar.propTypes = {
size: isIconSizeTokenProp,
src: PropTypes.string,
name: PropTypes.string.isRequired,
icon: PropTypes.func,
};

export {Avatar};
9 changes: 9 additions & 0 deletions packages/paste-core/components/avatar/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type {IconSize} from '@twilio-paste/style-props';
import type {GenericIconProps} from '@twilio-paste/icons/esm/types';

export interface AvatarProps extends React.HTMLAttributes<'div'> {
name: string;
size: IconSize;
src?: string;
icon?: React.FC<GenericIconProps>;
}
106 changes: 79 additions & 27 deletions packages/paste-core/components/avatar/src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {
import type {
IconSizeOptions,
LineHeightOptions,
FontSizeOptions,
Expand All @@ -19,33 +19,82 @@ export const getInitialsFromName = (fullname: string): string => {
}, '');
};

export const getCorrespondingLineHeightFromSizeToken = (size: IconSizeOptions): LineHeightOptions =>
size.replace('sizeIcon', 'lineHeight') as LineHeightOptions;
export const getCorrespondingLineHeightFromSizeToken = (size: IconSizeOptions): LineHeightOptions => {
if (typeof size === 'string' && size.includes('sizeIcon')) {
return size.replace('sizeIcon', 'lineHeight') as LineHeightOptions;
}
throw new Error('[Avatar]: size must be of type IconSizeOptions.');
};

export const getCorrespondingFontSizeFromSizeToken = (size: IconSizeOptions): FontSizeOptions => {
switch (size) {
case 'sizeIcon10':
case 'sizeIcon20':
case 'sizeIcon30':
case 'sizeIcon40':
case 'sizeIcon50':
case 'sizeIcon60':
default:
return 'fontSize10';
case 'sizeIcon70':
return 'fontSize20';
case 'sizeIcon80':
return 'fontSize30';
case 'sizeIcon90':
return 'fontSize40';
case 'sizeIcon100':
return 'fontSize60';
case 'sizeIcon110':
return 'fontSize70';
if (typeof size === 'string' && size.includes('sizeIcon')) {
switch (size) {
case 'sizeIcon10':
case 'sizeIcon20':
case 'sizeIcon30':
case 'sizeIcon40':
case 'sizeIcon50':
case 'sizeIcon60':
default:
return 'fontSize10';
case 'sizeIcon70':
return 'fontSize20';
case 'sizeIcon80':
return 'fontSize30';
case 'sizeIcon90':
return 'fontSize40';
case 'sizeIcon100':
return 'fontSize60';
case 'sizeIcon110':
return 'fontSize70';
}
}
throw new Error('[Avatar]: size must be of type IconSizeOptions.');
};

/**
* Uses IconSizeOptions to return a smaller IconSize
*/
export const getCorrespondingIconSizeFromSizeToken = (size: IconSizeOptions): IconSizeOptions => {
if (typeof size === 'string' && size.includes('sizeIcon')) {
switch (size) {
case 'sizeIcon10':
case 'sizeIcon20':
case 'sizeIcon30':
case 'sizeIcon40':
default:
return 'sizeIcon10';
case 'sizeIcon50':
case 'sizeIcon60':
return 'sizeIcon20';
case 'sizeIcon70':
return 'sizeIcon30';
case 'sizeIcon80':
return 'sizeIcon40';
case 'sizeIcon90':
return 'sizeIcon50';
case 'sizeIcon100':
return 'sizeIcon70';
case 'sizeIcon110':
return 'sizeIcon80';
}
}
throw new Error('[Avatar]: size must be of type IconSizeOptions.');
};

export const getComputedTokenNames = (size: IconSize): {lineHeight: LineHeight; fontSize: FontSize} => {
// this function takes in a size and exports an object w/ lineheight and fontsize and iconsize
export const getComputedTokenNames = (
size: IconSize
): {lineHeight: LineHeight; fontSize: FontSize; iconSize: IconSize} => {
if (typeof size === 'string') {
// if size is a string, put it into the correspondingSize function
return {
lineHeight: getCorrespondingLineHeightFromSizeToken(size),
fontSize: getCorrespondingFontSizeFromSizeToken(size),
iconSize: getCorrespondingIconSizeFromSizeToken(size),
};
}
// check to see if size is an array, map over it if it is
if (Array.isArray(size)) {
return {
lineHeight: size.map((s) => {
Expand All @@ -60,10 +109,13 @@ export const getComputedTokenNames = (size: IconSize): {lineHeight: LineHeight;
}
return null;
}),
iconSize: size.map((s) => {
if (s != null) {
return getCorrespondingIconSizeFromSizeToken(s);
}
return null;
}),
};
}
return {
lineHeight: getCorrespondingLineHeightFromSizeToken(size as IconSizeOptions),
fontSize: getCorrespondingFontSizeFromSizeToken(size as IconSizeOptions),
};
throw new Error('[Avatar]: size must be a string or an array');
};
Loading

2 comments on commit 3419ff6

@vercel
Copy link

@vercel vercel bot commented on 3419ff6 Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vercel
Copy link

@vercel vercel bot commented on 3419ff6 Mar 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

paste – ./

paste-twilio-dsys.vercel.app
paste-git-main-twilio-dsys.vercel.app
paste.twilio.design

Please # to comment.