Skip to content

Commit

Permalink
feat(avatar): add avatar group (#3218)
Browse files Browse the repository at this point in the history
  • Loading branch information
nkrantz committed May 11, 2023
1 parent 0b3ce0a commit d0ea875
Show file tree
Hide file tree
Showing 23 changed files with 552 additions and 234 deletions.
6 changes: 6 additions & 0 deletions .changeset/clever-pears-burn.md
@@ -0,0 +1,6 @@
---
'@twilio-paste/avatar': minor
'@twilio-paste/core': minor
---

[Avatar] Add Avatar Group component
6 changes: 6 additions & 0 deletions .changeset/smooth-ducks-crash.md
@@ -0,0 +1,6 @@
---
'@twilio-paste/design-tokens': patch
'@twilio-paste/core': patch
---

[Design tokens] adjust avatar border tokens for default and dark themes
5 changes: 5 additions & 0 deletions .changeset/swift-pears-visit.md
@@ -0,0 +1,5 @@
---
'@twilio-paste/codemods': patch
---

[Codemods] add AvatarGroup to `@twilio-paste/avatar` package
6 changes: 6 additions & 0 deletions .changeset/thick-worms-begin.md
@@ -0,0 +1,6 @@
---
'@twilio-paste/design-tokens': patch
'@twilio-paste/core': patch
---

[Design tokens] update `shadow-border-user` token to fix grouped avatars bug in default theme
1 change: 1 addition & 0 deletions packages/paste-codemods/tools/.cache/mappings.json
Expand Up @@ -9,6 +9,7 @@
"isExternalUrl": "@twilio-paste/core/anchor",
"secureExternalLink": "@twilio-paste/core/anchor",
"Avatar": "@twilio-paste/core/avatar",
"AvatarGroup": "@twilio-paste/core/avatar",
"Badge": "@twilio-paste/core/badge",
"badgeBaseStyles": "@twilio-paste/core/badge",
"useResizeChildIcons": "@twilio-paste/core/badge",
Expand Down
10 changes: 10 additions & 0 deletions packages/paste-core/components/avatar/__tests__/avatar.test.tsx
Expand Up @@ -11,6 +11,7 @@ import {
getCorrespondingIconSizeFromSizeToken,
getComputedTokenNames,
getInitialsFromName,
getGroupSpacing,
} from '../src/utils';

describe('Avatar', () => {
Expand Down Expand Up @@ -105,6 +106,15 @@ describe('Avatar', () => {
});
});
});

describe('getGroupSpacing', () => {
it('should return the correct space token', () => {
expect(getGroupSpacing('sizeIcon30', 'user')).toEqual('spaceNegative10');
expect(getGroupSpacing('sizeIcon100', 'user')).toEqual('spaceNegative40');
expect(getGroupSpacing('sizeIcon30', 'entity')).toEqual('spaceNegative10');
expect(getGroupSpacing('sizeIcon100', 'entity')).toEqual('spaceNegative20');
});
});
});

describe('intials', () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/paste-core/components/avatar/package.json
Expand Up @@ -3,7 +3,7 @@
"version": "8.0.0",
"category": "graphic",
"status": "production",
"description": "An Avatar is a graphical representation of a user or entity.",
"description": "An Avatar is a graphical representation of a user or entity and an Avatar Group is a collection of Avatars.",
"author": "Twilio Inc.",
"license": "MIT",
"main:dev": "src/index.tsx",
Expand Down Expand Up @@ -34,6 +34,7 @@
"@twilio-paste/customization": "^7.0.0",
"@twilio-paste/design-tokens": "^9.0.0",
"@twilio-paste/icons": "^11.0.0",
"@twilio-paste/stack": "^7.0.0",
"@twilio-paste/style-props": "^8.0.0",
"@twilio-paste/styling-library": "^2.0.0",
"@twilio-paste/text": "^9.0.0",
Expand All @@ -53,6 +54,7 @@
"@twilio-paste/customization": "^7.0.0",
"@twilio-paste/design-tokens": "^9.0.2",
"@twilio-paste/icons": "^11.0.0",
"@twilio-paste/stack": "^7.0.0",
"@twilio-paste/style-props": "^8.0.0",
"@twilio-paste/styling-library": "^2.0.0",
"@twilio-paste/text": "^9.0.0",
Expand Down
155 changes: 155 additions & 0 deletions packages/paste-core/components/avatar/src/Avatar.tsx
@@ -0,0 +1,155 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import {isValidElementType} from 'react-is';
import {Text} from '@twilio-paste/text';
import {Box, safelySpreadBoxProps} from '@twilio-paste/box';
import type {BoxStyleProps} from '@twilio-paste/box';
import {isIconSizeTokenProp} from '@twilio-paste/style-props';

import {getComputedTokenNames, getInitialsFromName} from './utils';
import type {AvatarProps, AvatarContentProps, ColorVariants, AvatarVariants} from './types';
import {AvatarGroupContext} from './AvatarGroup';

const DEFAULT_SIZE = 'sizeIcon70';

const AvatarContents: React.FC<React.PropsWithChildren<AvatarContentProps>> = ({
name,
size = DEFAULT_SIZE,
src,
icon: Icon,
}) => {
const {size: groupSize} = React.useContext(AvatarGroupContext);
const computedTokenNames = getComputedTokenNames(groupSize || size);

if (src != null) {
return (
<Box as="img" alt={name} maxWidth="100%" boxShadow="shadowBorderWeaker" src={src} size={size} title={name} />
);
}
if (Icon != null) {
if (!isValidElementType(Icon) || 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} />
</Box>
);
}
return (
<Text
as="abbr"
display="inline-block"
fontSize={computedTokenNames.fontSize}
fontWeight="fontWeightSemibold"
lineHeight={computedTokenNames.lineHeight}
verticalAlign="top"
textDecoration="none"
title={name}
color="inherit"
>
{getInitialsFromName(name)}
</Text>
);
};

AvatarContents.displayName = 'AvatarContents';

const colorVariants: Record<ColorVariants, BoxStyleProps> = {
default: {
backgroundColor: 'colorBackgroundUser',
color: 'colorTextUser',
},
decorative10: {
backgroundColor: 'colorBackgroundDecorative10Weakest',
color: 'colorTextDecorative10',
},
decorative20: {
backgroundColor: 'colorBackgroundDecorative20Weakest',
color: 'colorTextDecorative20',
},
decorative30: {
backgroundColor: 'colorBackgroundDecorative30Weakest',
color: 'colorTextDecorative30',
},
decorative40: {
backgroundColor: 'colorBackgroundDecorative40Weakest',
color: 'colorTextDecorative40',
},
};

const variants: Record<AvatarVariants, BoxStyleProps> = {
user: {
borderRadius: 'borderRadiusCircle',
},
entity: {
borderRadius: 'borderRadius30',
},
};

const shadowVariants: Record<ColorVariants, BoxStyleProps> = {
default: {
boxShadow: 'shadowBorderUser',
},
decorative10: {
boxShadow: 'shadowBorderDecorative10Weaker',
},
decorative20: {
boxShadow: 'shadowBorderDecorative20Weaker',
},
decorative30: {
boxShadow: 'shadowBorderDecorative30Weaker',
},
decorative40: {
boxShadow: 'shadowBorderDecorative40Weaker',
},
};

export const Avatar = React.forwardRef<HTMLDivElement, AvatarProps>(
({name, size = DEFAULT_SIZE, element = 'AVATAR', src, icon, color = 'default', variant = 'user', ...props}, ref) => {
if (name === undefined) {
// eslint-disable-next-line no-console
console.error('[Paste Avatar]: name prop is required');
}
const {variant: groupVariant, size: groupSize} = React.useContext(AvatarGroupContext);

return (
<Box
{...safelySpreadBoxProps(props)}
as="div"
element={element}
overflow="hidden"
textAlign="center"
flexShrink={0}
ref={ref}
size={groupSize || size}
{...(src ? undefined : shadowVariants[color])}
{...colorVariants[color]}
{...variants[groupVariant || variant]}
>
<AvatarContents name={name} size={size} icon={icon} src={src} />
</Box>
);
}
);

Avatar.displayName = 'Avatar';

Avatar.propTypes = {
size: isIconSizeTokenProp,
name: PropTypes.string.isRequired,
element: PropTypes.string,
src: PropTypes.string,
color: PropTypes.oneOf([
'default',
'decorative10',
'decorative20',
'decorative30',
'decorative40',
] as ColorVariants[]),
variant: PropTypes.oneOf(['user', 'entity'] as AvatarVariants[]),
icon: (props) => {
if (typeof props.icon !== 'function') new Error('[Paste Avatar]: icon prop must be a Paste Icon');
return null;
},
};
30 changes: 30 additions & 0 deletions packages/paste-core/components/avatar/src/AvatarGroup.tsx
@@ -0,0 +1,30 @@
import * as React from 'react';
import PropTypes from 'prop-types';
import {Stack} from '@twilio-paste/stack';
import {isIconSizeTokenProp} from '@twilio-paste/style-props';

import type {AvatarGroupProps, AvatarVariants} from './types';
import {getGroupSpacing} from './utils';

export const AvatarGroupContext = React.createContext<Omit<AvatarGroupProps, 'children'>>({} as AvatarGroupProps);

export const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
({variant, size, children, element = 'AVATAR_GROUP'}, ref) => {
return (
<AvatarGroupContext.Provider value={{variant, size}}>
<Stack orientation="horizontal" spacing={getGroupSpacing(size, variant)} element={element} ref={ref}>
{children}
</Stack>
</AvatarGroupContext.Provider>
);
}
);

AvatarGroup.displayName = 'AvatarGroup';

AvatarGroup.propTypes = {
size: isIconSizeTokenProp,
variant: PropTypes.oneOf(['user', 'entity'] as AvatarVariants[]),
children: PropTypes.node.isRequired,
element: PropTypes.string,
};

0 comments on commit d0ea875

Please sign in to comment.