From 3419ff64a07056ae365956d03ec62f1e5e619f29 Mon Sep 17 00:00:00 2001 From: Nora Krantz <75342690+nkrantz@users.noreply.github.com> Date: Thu, 25 Mar 2021 19:27:15 -0400 Subject: [PATCH] feat(avatar): can add icon inside avatar (#1281) * 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 Co-authored-by: Nora Krantz Co-authored-by: TheSisb --- .changeset/spotty-parrots-look.md | 6 + .../avatar/__tests__/avatar.test.tsx | 76 ++++++++++++- .../paste-core/components/avatar/package.json | 2 + .../components/avatar/src/index.tsx | 73 ++++++------ .../paste-core/components/avatar/src/types.ts | 9 ++ .../paste-core/components/avatar/src/utils.ts | 106 +++++++++++++----- .../avatar/stories/index.stories.tsx | 27 +++++ .../disclosure/stories/index.stories.tsx | 5 +- 8 files changed, 241 insertions(+), 63 deletions(-) create mode 100644 .changeset/spotty-parrots-look.md create mode 100644 packages/paste-core/components/avatar/src/types.ts diff --git a/.changeset/spotty-parrots-look.md b/.changeset/spotty-parrots-look.md new file mode 100644 index 0000000000..91c4156e92 --- /dev/null +++ b/.changeset/spotty-parrots-look.md @@ -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. diff --git a/packages/paste-core/components/avatar/__tests__/avatar.test.tsx b/packages/paste-core/components/avatar/__tests__/avatar.test.tsx index 10032d1069..0392da5b43 100644 --- a/packages/paste-core/components/avatar/__tests__/avatar.test.tsx +++ b/packages/paste-core/components/avatar/__tests__/avatar.test.tsx @@ -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'; @@ -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', () => { @@ -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'], }); }); }); @@ -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()).toThrow(); + // @ts-expect-error + // eslint-disable-next-line react/jsx-boolean-value + expect(() => render()).toThrow(); + // @ts-expect-error + expect(() => render(} />)).toThrow(); + }); + }); + + describe('Render an icon and its attributes', () => { + it('should render an svg', () => { + render(); + const avatarComponent = screen.getByTestId('avatar'); + expect(avatarComponent.querySelectorAll('svg').length).toEqual(1); + }); + it('should not have an aria-hidden attribute set to true', () => { + render(); + 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(); + const avatarComponent = screen.getByTestId('avatar'); + expect(avatarComponent.querySelector('title')).toHaveTextContent(name); + }); + }); + describe('accessibility', () => { it('should have no accessibility violations', async () => { const {container} = render( <> + ); const results = await axe(container); diff --git a/packages/paste-core/components/avatar/package.json b/packages/paste-core/components/avatar/package.json index a371c158a5..6fa4d703bd 100644 --- a/packages/paste-core/components/avatar/package.json +++ b/packages/paste-core/components/avatar/package.json @@ -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", @@ -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", diff --git a/packages/paste-core/components/avatar/src/index.tsx b/packages/paste-core/components/avatar/src/index.tsx index 4e6e3e256e..3e6837d04b 100644 --- a/packages/paste-core/components/avatar/src/index.tsx +++ b/packages/paste-core/components/avatar/src/index.tsx @@ -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 = ({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 ( + + + + ); + } + if (src != null) { + return ; + } + return ( + + {getInitialsFromName(name)} + + ); +}; -export interface AvatarProps extends React.HTMLAttributes<'div'> { - name: string; - size: IconSize; - src?: string; -} const Avatar = React.forwardRef( - ({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 ( ( ref={ref} size={size} > - {src != null ? ( - - ) : ( - - {getInitialsFromName(name)} - - )} + ); } @@ -60,6 +64,7 @@ Avatar.propTypes = { size: isIconSizeTokenProp, src: PropTypes.string, name: PropTypes.string.isRequired, + icon: PropTypes.func, }; export {Avatar}; diff --git a/packages/paste-core/components/avatar/src/types.ts b/packages/paste-core/components/avatar/src/types.ts new file mode 100644 index 0000000000..de8b4ccbe6 --- /dev/null +++ b/packages/paste-core/components/avatar/src/types.ts @@ -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; +} diff --git a/packages/paste-core/components/avatar/src/utils.ts b/packages/paste-core/components/avatar/src/utils.ts index becddbc31b..9641162e41 100644 --- a/packages/paste-core/components/avatar/src/utils.ts +++ b/packages/paste-core/components/avatar/src/utils.ts @@ -1,4 +1,4 @@ -import { +import type { IconSizeOptions, LineHeightOptions, FontSizeOptions, @@ -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) => { @@ -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'); }; diff --git a/packages/paste-core/components/avatar/stories/index.stories.tsx b/packages/paste-core/components/avatar/stories/index.stories.tsx index 94e3896bc7..4aec121635 100644 --- a/packages/paste-core/components/avatar/stories/index.stories.tsx +++ b/packages/paste-core/components/avatar/stories/index.stories.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import {Stack} from '@twilio-paste/stack'; +import {UserIcon} from '@twilio-paste/icons/esm/UserIcon'; import Avatar10 from '../../../../../.storybook/static/avatars/avatar-sizeIcon10.png'; import Avatar20 from '../../../../../.storybook/static/avatars/avatar-sizeIcon20.png'; import Avatar30 from '../../../../../.storybook/static/avatars/avatar-sizeIcon30.png'; @@ -72,6 +73,24 @@ export const Image = (): React.ReactNode => { ); }; +export const Icon = (): React.ReactNode => { + return ( + + + + + + + + + + + + + + ); +}; + Image.story = { parameters: {chromatic: {delay: 3000}}, }; @@ -92,6 +111,14 @@ export const ResponsiveImage = (): React.ReactNode => { ); }; +export const ResponsiveIcon = (): React.ReactNode => { + return ( + + + + ); +}; + ResponsiveImage.story = { parameters: {chromatic: {delay: 3000}}, }; diff --git a/packages/paste-core/primitives/disclosure/stories/index.stories.tsx b/packages/paste-core/primitives/disclosure/stories/index.stories.tsx index 65f744a03e..ffff6ce199 100644 --- a/packages/paste-core/primitives/disclosure/stories/index.stories.tsx +++ b/packages/paste-core/primitives/disclosure/stories/index.stories.tsx @@ -1,5 +1,8 @@ import * as React from 'react'; -import {Anchor, Heading, Paragraph, Separator} from '@twilio-paste/core'; +import {Anchor} from '@twilio-paste/anchor'; +import {Heading} from '@twilio-paste/heading'; +import {Paragraph} from '@twilio-paste/paragraph'; +import {Separator} from '@twilio-paste/separator'; import {useDisclosurePrimitiveState, DisclosurePrimitive, DisclosurePrimitiveContent} from '../src'; // eslint-disable-next-line import/no-default-export