From ff54236773f41128614fe3a75d178dd73664ff39 Mon Sep 17 00:00:00 2001 From: Szymon Graczyk Date: Mon, 1 Aug 2022 16:01:00 +0200 Subject: [PATCH 1/7] #373 Avatar component --- .../.storybook/preview-head.html | 3 +- .../src/components/Avatar/Avatar.helpers.ts | 16 + .../src/components/Avatar/Avatar.module.scss | 316 +++++++++++++++++ .../src/components/Avatar/Avatar.stories.tsx | 318 ++++++++++++++++++ .../src/components/Avatar/Avatar.tsx | 110 ++++++ 5 files changed, 762 insertions(+), 1 deletion(-) create mode 100644 packages/react-components/src/components/Avatar/Avatar.helpers.ts create mode 100644 packages/react-components/src/components/Avatar/Avatar.module.scss create mode 100644 packages/react-components/src/components/Avatar/Avatar.stories.tsx create mode 100644 packages/react-components/src/components/Avatar/Avatar.tsx diff --git a/packages/react-components/.storybook/preview-head.html b/packages/react-components/.storybook/preview-head.html index bf950f523..417d08486 100644 --- a/packages/react-components/.storybook/preview-head.html +++ b/packages/react-components/.storybook/preview-head.html @@ -19,10 +19,11 @@ } .story-spacer { + display: flex; margin-bottom: 5px; } .story-spacer > * + * { - margin-left: 5px; + margin-left: 15px; } .lc-dark-theme, diff --git a/packages/react-components/src/components/Avatar/Avatar.helpers.ts b/packages/react-components/src/components/Avatar/Avatar.helpers.ts new file mode 100644 index 000000000..95aa55ed8 --- /dev/null +++ b/packages/react-components/src/components/Avatar/Avatar.helpers.ts @@ -0,0 +1,16 @@ +import { getContrast } from 'polished'; + +export function getInitials(name = '', count = 2): string { + return name + .split(' ') + .map((el) => el.charAt(0)) + .join('') + .substring(0, count) + .toUpperCase(); +} + +export function getFontColor(color: string): string { + return getContrast(color, '#FFFFFF') > 4.5 + ? 'var(--content-white-locked)' + : 'var(--content-subtle)'; +} diff --git a/packages/react-components/src/components/Avatar/Avatar.module.scss b/packages/react-components/src/components/Avatar/Avatar.module.scss new file mode 100644 index 000000000..7b38ada3c --- /dev/null +++ b/packages/react-components/src/components/Avatar/Avatar.module.scss @@ -0,0 +1,316 @@ +$base-class: 'avatar'; + +.#{$base-class} { + align-items: center; + background-color: var(--surface-basic-disabled); + display: flex; + font-weight: 600; + justify-content: center; + position: relative; + + &__status { + $status-class: &; + + border: 1px solid var(--background); + border-radius: 50%; + position: absolute; + + &--available { + background: var(--color-positive-default); + } + + &--unavailable { + background: var(--color-negative-default); + } + + &--unknown { + background: var(--surface-secondary-default); + } + + &--xxxsmall { + height: 8px; + width: 8px; + + &#{$status-class}--circle { + bottom: 62.5%; + left: 62.5%; + } + + &#{$status-class}--rounded-square { + bottom: 62.5%; + left: 62.5%; + } + } + + &--xxsmall { + height: 8px; + width: 8px; + + &#{$status-class}--circle { + bottom: 60%; + left: 60%; + } + + &#{$status-class}--rounded-square { + bottom: 70%; + left: 70%; + } + } + + &--xsmall { + height: 8px; + width: 8px; + + &#{$status-class}--circle { + bottom: 66.67%; + left: 66.67%; + } + + &#{$status-class}--rounded-square { + bottom: 75%; + left: 75%; + } + } + + &--small { + height: 10px; + width: 10px; + + &#{$status-class}--circle { + bottom: 68.75%; + left: 68.75%; + } + + &#{$status-class}--rounded-square { + bottom: 75%; + left: 75%; + } + } + + &--medium { + height: 12px; + width: 12px; + + &#{$status-class}--circle { + bottom: 66.67%; + left: 66.67%; + } + + &#{$status-class}--rounded-square { + bottom: 72.92%; + left: 72.92%; + } + } + + &--large { + height: 16px; + width: 16px; + + &#{$status-class}--circle { + bottom: 66.67%; + left: 66.67%; + } + + &#{$status-class}--rounded-square { + bottom: 72.92%; + left: 72.92%; + } + } + + &--xlarge { + height: 16px; + width: 16px; + + &#{$status-class}--circle { + bottom: 75%; + left: 75%; + } + + &#{$status-class}--rounded-square { + bottom: 81.25%; + left: 81.25%; + } + } + + &--xxlarge { + height: 24px; + width: 24px; + + &#{$status-class}--circle { + bottom: 75%; + left: 75%; + } + + &#{$status-class}--rounded-square { + bottom: 81.25%; + left: 81.25%; + } + } + } + + &__rim { + background: transparent; + border-color: var(--color-negative-default); + border-radius: inherit; + border-style: solid; + box-sizing: content-box; + display: block; + left: 50%; + position: absolute; + top: 50%; + transform: translate(-50%, -50%); + + &--xxxsmall, + &--xxsmall, + &--xsmall { + border-width: 2px; + height: calc(100% + 2px); + width: calc(100% + 2px); + } + + &--small, + &--medium, + &--large { + border-width: 3px; + height: calc(100% + 4px); + width: calc(100% + 4px); + } + + &--xlarge, + &--xxlarge { + border-width: 4px; + height: calc(100% + 8px); + width: calc(100% + 8px); + } + } + + &__image { + border-radius: inherit; + height: 100%; + object-fit: cover; + width: 100%; + } + + &--circle { + border-radius: 50%; + } + + &--rounded-square { + border-radius: 4px; + } + + &--xxxsmall, + &--xxsmall, + &--xsmall { + margin: 3px; + } + + &--small, + &--medium, + &--large { + margin: 5px; + } + + &--xlarge, + &--xxlarge { + margin: 8px; + } + + &--xxxsmall { + font-size: 12px; + height: 16px; + line-height: 20px; + width: 16px; + } + + &--xxsmall { + font-size: 12px; + height: 20px; + line-height: 20px; + width: 20px; + } + + &--xsmall { + font-size: 12px; + height: 24px; + line-height: 20px; + width: 24px; + } + + &--small { + font-size: 15px; + height: 32px; + line-height: 22px; + width: 32px; + } + + &--medium { + font-size: 15px; + height: 36px; + line-height: 22px; + width: 36px; + } + + &--large { + font-size: 18px; + height: 48px; + line-height: 24px; + width: 48px; + } + + &--xlarge { + font-size: 24px; + height: 64px; + line-height: 32px; + width: 64px; + } + + &--xxlarge { + font-size: 32px; + height: 96px; + line-height: 40px; + width: 96px; + } + + &__icon { + &--xxxsmall svg { + height: 8px; + width: 8px; + } + + &--xxsmall svg { + height: 10px; + width: 10px; + } + + &--xsmall svg { + height: 12px; + width: 12px; + } + + &--small svg { + height: 16px; + width: 16px; + } + + &--medium svg { + height: 18px; + width: 18px; + } + + &--large svg { + height: 24px; + width: 24px; + } + + &--xlarge svg { + height: 32px; + width: 32px; + } + + &--xxlarge svg { + height: 48px; + width: 48px; + } + } +} diff --git a/packages/react-components/src/components/Avatar/Avatar.stories.tsx b/packages/react-components/src/components/Avatar/Avatar.stories.tsx new file mode 100644 index 000000000..02052b806 --- /dev/null +++ b/packages/react-components/src/components/Avatar/Avatar.stories.tsx @@ -0,0 +1,318 @@ +import * as React from 'react'; +import { ComponentMeta, Story } from '@storybook/react'; + +import { StoryDescriptor } from '../../stories/components/StoryDescriptor'; + +import { Avatar, AvatarProps } from './Avatar'; + +export default { + title: 'Components/Avatar', + component: Avatar, +} as ComponentMeta; + +export const Default: Story = (args: AvatarProps) => ( + +); + +const defaultImage = + 'https://cdn.livechatinc.com/cloud/?uri=https://livechat.s3.amazonaws.com/default/avatars/female_63.jpg'; +const defaultName = 'John Doe'; + +Default.storyName = 'Avatar'; +Default.args = { + type: 'text', + text: defaultName, +}; + +export const Types = (): JSX.Element => ( + <> + + + + + + + +); + +export const Shapes = (): JSX.Element => ( + <> + + + + + + + +); + +export const Sizes = (): JSX.Element => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const Statuses = (): JSX.Element => ( + <> + + + + + + + + + + +); + +export const Colors = (): JSX.Element => ( + <> + + + + + + + + + +); + +export const FallbackAvatar = (): JSX.Element => ( + +); + +export const Rim = (): JSX.Element => ( + <> + + + {' '} + + + + +); + +export const SizesWithStatus = (): JSX.Element => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); + +export const SizesWithRim = (): JSX.Element => ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +); diff --git a/packages/react-components/src/components/Avatar/Avatar.tsx b/packages/react-components/src/components/Avatar/Avatar.tsx new file mode 100644 index 000000000..43ee39ade --- /dev/null +++ b/packages/react-components/src/components/Avatar/Avatar.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; +import clsx from 'clsx'; +import { Person as PersonIcon } from '@livechat/design-system-icons/react/material'; + +import { Icon } from '../Icon'; + +import { getFontColor, getInitials } from './Avatar.helpers'; +import styles from './Avatar.module.scss'; + +type AvatarShape = 'circle' | 'rounded-square'; +type AvatarSize = + | 'xxxsmall' + | 'xxsmall' + | 'xsmall' + | 'small' + | 'medium' + | 'large' + | 'xlarge' + | 'xxlarge'; +type AvatarStatus = 'available' | 'unavailable' | 'unknown'; +type AvatarType = 'image' | 'text'; + +export interface AvatarProps { + alt?: string; + className?: string; + color?: string; + shape?: AvatarShape; + size?: AvatarSize; + src?: string; + status?: AvatarStatus; + text?: string; + type: AvatarType; + withRim?: boolean; +} + +const baseClass = 'avatar'; +const defaultBackgroundColor = 'var(--surface-basic-disabled)'; +const defaultFontColor = 'var(--content-subtle)'; + +export const Avatar: React.FC = ({ + alt, + className, + color, + shape = 'circle', + size = 'medium', + src, + status, + text, + type, + withRim = false, +}) => { + const [shouldDisplayFallbackAvatar, setShouldDisplayFallbackAvatar] = + React.useState(false); + + const shouldDisplayImage = type === 'image' && !shouldDisplayFallbackAvatar; + const shouldDisplayInitials = type === 'text'; + const letterCount = ['xxxsmall', 'xxsmall', 'xsmall'].includes(size) ? 1 : 2; + const initials = getInitials(text, letterCount); + const backgroundColor = color || defaultBackgroundColor; + const fontColor = color ? getFontColor(color) : defaultFontColor; + + const mergedClassNames = clsx( + styles[baseClass], + styles[`${baseClass}--${shape}`], + styles[`${baseClass}--${size}`], + className + ); + const mergedStatusClassNames = clsx( + styles[`${baseClass}__status`], + styles[`${baseClass}__status--${shape}`], + styles[`${baseClass}__status--${size}`], + styles[`${baseClass}__status--${status}`] + ); + const mergedIconClassNames = clsx( + styles[`${baseClass}__icon`], + styles[`${baseClass}__icon--${size}`] + ); + const mergedRimClassNames = clsx( + styles[`${baseClass}__rim`], + styles[`${baseClass}__rim--${size}`] + ); + + const handleError: React.ReactEventHandler | undefined = + React.useCallback(() => setShouldDisplayFallbackAvatar(true), []); + + return ( +
+ {withRim &&
} + {status &&
} + {shouldDisplayImage && ( + {alt} + )} + {shouldDisplayInitials && ( + {initials} + )} + {shouldDisplayFallbackAvatar && ( + + )} +
+ ); +}; From 4a6bf252c9e4db11f83a45fc3507f972ae99eb10 Mon Sep 17 00:00:00 2001 From: Szymon Graczyk Date: Wed, 3 Aug 2022 14:03:44 +0200 Subject: [PATCH 2/7] Introduce gap for StoryDescriptor to avoid margin issues. Decrease the rim width for larger Avatars. --- .../.storybook/preview-head.html | 4 +- .../src/components/Avatar/Avatar.module.scss | 114 ++++++++---------- .../src/components/Avatar/Avatar.tsx | 21 ++-- 3 files changed, 65 insertions(+), 74 deletions(-) diff --git a/packages/react-components/.storybook/preview-head.html b/packages/react-components/.storybook/preview-head.html index 417d08486..0cd36cb4e 100644 --- a/packages/react-components/.storybook/preview-head.html +++ b/packages/react-components/.storybook/preview-head.html @@ -21,9 +21,7 @@ .story-spacer { display: flex; margin-bottom: 5px; - } - .story-spacer > * + * { - margin-left: 15px; + gap: 5px; } .lc-dark-theme, diff --git a/packages/react-components/src/components/Avatar/Avatar.module.scss b/packages/react-components/src/components/Avatar/Avatar.module.scss index 7b38ada3c..a474a2534 100644 --- a/packages/react-components/src/components/Avatar/Avatar.module.scss +++ b/packages/react-components/src/components/Avatar/Avatar.module.scss @@ -170,18 +170,13 @@ $base-class: 'avatar'; &--small, &--medium, - &--large { + &--large, + &--xlarge, + &--xxlarge { border-width: 3px; height: calc(100% + 4px); width: calc(100% + 4px); } - - &--xlarge, - &--xxlarge { - border-width: 4px; - height: calc(100% + 8px); - width: calc(100% + 8px); - } } &__image { @@ -191,6 +186,48 @@ $base-class: 'avatar'; width: 100%; } + &__icon { + &--xxxsmall svg { + height: 8px; + width: 8px; + } + + &--xxsmall svg { + height: 10px; + width: 10px; + } + + &--xsmall svg { + height: 12px; + width: 12px; + } + + &--small svg { + height: 16px; + width: 16px; + } + + &--medium svg { + height: 18px; + width: 18px; + } + + &--large svg { + height: 24px; + width: 24px; + } + + &--xlarge svg { + height: 32px; + width: 32px; + } + + &--xxlarge svg { + height: 48px; + width: 48px; + } + } + &--circle { border-radius: 50%; } @@ -199,23 +236,20 @@ $base-class: 'avatar'; border-radius: 4px; } - &--xxxsmall, - &--xxsmall, - &--xsmall { + &--with-rim.#{$base-class}--xxxsmall, + &--with-rim.#{$base-class}--xxsmall, + &--with-rim.#{$base-class}--xsmall { margin: 3px; } - &--small, - &--medium, - &--large { + &--with-rim.#{$base-class}--small, + &--with-rim.#{$base-class}--medium, + &--with-rim.#{$base-class}--large, + &--with-rim.#{$base-class}--xlarge, + &--with-rim.#{$base-class}--xxlarge { margin: 5px; } - &--xlarge, - &--xxlarge { - margin: 8px; - } - &--xxxsmall { font-size: 12px; height: 16px; @@ -271,46 +305,4 @@ $base-class: 'avatar'; line-height: 40px; width: 96px; } - - &__icon { - &--xxxsmall svg { - height: 8px; - width: 8px; - } - - &--xxsmall svg { - height: 10px; - width: 10px; - } - - &--xsmall svg { - height: 12px; - width: 12px; - } - - &--small svg { - height: 16px; - width: 16px; - } - - &--medium svg { - height: 18px; - width: 18px; - } - - &--large svg { - height: 24px; - width: 24px; - } - - &--xlarge svg { - height: 32px; - width: 32px; - } - - &--xxlarge svg { - height: 48px; - width: 48px; - } - } } diff --git a/packages/react-components/src/components/Avatar/Avatar.tsx b/packages/react-components/src/components/Avatar/Avatar.tsx index 43ee39ade..f618aab29 100644 --- a/packages/react-components/src/components/Avatar/Avatar.tsx +++ b/packages/react-components/src/components/Avatar/Avatar.tsx @@ -1,5 +1,5 @@ import * as React from 'react'; -import clsx from 'clsx'; +import cx from 'clsx'; import { Person as PersonIcon } from '@livechat/design-system-icons/react/material'; import { Icon } from '../Icon'; @@ -59,23 +59,24 @@ export const Avatar: React.FC = ({ const backgroundColor = color || defaultBackgroundColor; const fontColor = color ? getFontColor(color) : defaultFontColor; - const mergedClassNames = clsx( - styles[baseClass], - styles[`${baseClass}--${shape}`], - styles[`${baseClass}--${size}`], - className - ); - const mergedStatusClassNames = clsx( + const mergedClassNames = cx({ + [styles[baseClass]]: true, + [styles[`${baseClass}--${shape}`]]: true, + [styles[`${baseClass}--${size}`]]: true, + [styles[`${baseClass}--with-rim`]]: withRim, + className, + }); + const mergedStatusClassNames = cx( styles[`${baseClass}__status`], styles[`${baseClass}__status--${shape}`], styles[`${baseClass}__status--${size}`], styles[`${baseClass}__status--${status}`] ); - const mergedIconClassNames = clsx( + const mergedIconClassNames = cx( styles[`${baseClass}__icon`], styles[`${baseClass}__icon--${size}`] ); - const mergedRimClassNames = clsx( + const mergedRimClassNames = cx( styles[`${baseClass}__rim`], styles[`${baseClass}__rim--${size}`] ); From 11d479a6f4231f55ba1909f39b7aaab25aa8eb5e Mon Sep 17 00:00:00 2001 From: Szymon Graczyk Date: Wed, 3 Aug 2022 16:25:52 +0200 Subject: [PATCH 3/7] Use locked colors for custom background Avatar --- .../react-components/src/components/Avatar/Avatar.helpers.ts | 4 ++-- packages/react-components/src/components/Avatar/index.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 packages/react-components/src/components/Avatar/index.ts diff --git a/packages/react-components/src/components/Avatar/Avatar.helpers.ts b/packages/react-components/src/components/Avatar/Avatar.helpers.ts index 95aa55ed8..bbee97d33 100644 --- a/packages/react-components/src/components/Avatar/Avatar.helpers.ts +++ b/packages/react-components/src/components/Avatar/Avatar.helpers.ts @@ -11,6 +11,6 @@ export function getInitials(name = '', count = 2): string { export function getFontColor(color: string): string { return getContrast(color, '#FFFFFF') > 4.5 - ? 'var(--content-white-locked)' - : 'var(--content-subtle)'; + ? 'var(--color-white)' + : 'var(--color-black)'; } diff --git a/packages/react-components/src/components/Avatar/index.ts b/packages/react-components/src/components/Avatar/index.ts new file mode 100644 index 000000000..d3fb6dfa7 --- /dev/null +++ b/packages/react-components/src/components/Avatar/index.ts @@ -0,0 +1 @@ +export { Avatar } from './Avatar'; From 253737c3c13da91f847da2a55b10005f84f77273 Mon Sep 17 00:00:00 2001 From: Szymon Graczyk Date: Wed, 3 Aug 2022 17:23:36 +0200 Subject: [PATCH 4/7] Set status position the same for all sizes per shape --- .../src/components/Avatar/Avatar.module.scss | 136 ++++++------------ 1 file changed, 45 insertions(+), 91 deletions(-) diff --git a/packages/react-components/src/components/Avatar/Avatar.module.scss b/packages/react-components/src/components/Avatar/Avatar.module.scss index a474a2534..ee5cc07aa 100644 --- a/packages/react-components/src/components/Avatar/Avatar.module.scss +++ b/packages/react-components/src/components/Avatar/Avatar.module.scss @@ -10,6 +10,8 @@ $base-class: 'avatar'; &__status { $status-class: &; + $circle-class: #{$status-class}--circle; + $rounded-square-class: #{$status-class}--rounded-square; border: 1px solid var(--background); border-radius: 50%; @@ -27,124 +29,76 @@ $base-class: 'avatar'; background: var(--surface-secondary-default); } - &--xxxsmall { - height: 8px; - width: 8px; + &--xxxsmall#{$circle-class}, + &--xxsmall#{$circle-class}, + &--xsmall#{$circle-class}, + &--small#{$circle-class}, + &--medium#{$circle-class}, + &--large#{$circle-class}, + &--xlarge#{$circle-class}, + &--xxlarge#{$circle-class} { + bottom: 75%; + left: 75%; + } - &#{$status-class}--circle { - bottom: 62.5%; - left: 62.5%; - } + &--xxxsmall#{$rounded-square-class}, + &--xxsmall#{$rounded-square-class}, + &--xsmall#{$rounded-square-class}, + &--small#{$rounded-square-class}, + &--medium#{$rounded-square-class}, + &--large#{$rounded-square-class}, + &--xlarge#{$rounded-square-class}, + &--xxlarge#{$rounded-square-class} { + bottom: 81.25%; + left: 81.25%; + } - &#{$status-class}--rounded-square { - bottom: 62.5%; - left: 62.5%; - } + &--xxxsmall { + border-width: calc(4px * 0.125); + height: 4px; + width: 4px; } &--xxsmall { - height: 8px; - width: 8px; - - &#{$status-class}--circle { - bottom: 60%; - left: 60%; - } - - &#{$status-class}--rounded-square { - bottom: 70%; - left: 70%; - } + border-width: calc(5px * 0.125); + height: 5px; + width: 5px; } &--xsmall { - height: 8px; - width: 8px; - - &#{$status-class}--circle { - bottom: 66.67%; - left: 66.67%; - } - - &#{$status-class}--rounded-square { - bottom: 75%; - left: 75%; - } + border-width: calc(6px * 0.125); + height: 6px; + width: 6px; } &--small { - height: 10px; - width: 10px; - - &#{$status-class}--circle { - bottom: 68.75%; - left: 68.75%; - } - - &#{$status-class}--rounded-square { - bottom: 75%; - left: 75%; - } + border-width: calc(8px * 0.125); + height: 8px; + width: 8px; } &--medium { - height: 12px; - width: 12px; - - &#{$status-class}--circle { - bottom: 66.67%; - left: 66.67%; - } - - &#{$status-class}--rounded-square { - bottom: 72.92%; - left: 72.92%; - } + border-width: calc(8px * 0.125); + height: 9px; + width: 9px; } &--large { - height: 16px; - width: 16px; - - &#{$status-class}--circle { - bottom: 66.67%; - left: 66.67%; - } - - &#{$status-class}--rounded-square { - bottom: 72.92%; - left: 72.92%; - } + border-width: calc(12px * 0.125); + height: 12px; + width: 12px; } &--xlarge { + border-width: calc(16px * 0.125); height: 16px; width: 16px; - - &#{$status-class}--circle { - bottom: 75%; - left: 75%; - } - - &#{$status-class}--rounded-square { - bottom: 81.25%; - left: 81.25%; - } } &--xxlarge { + border-width: calc(24px * 0.125); height: 24px; width: 24px; - - &#{$status-class}--circle { - bottom: 75%; - left: 75%; - } - - &#{$status-class}--rounded-square { - bottom: 81.25%; - left: 81.25%; - } } } From 99b29a141f0edf68f0946b178b36731967670c83 Mon Sep 17 00:00:00 2001 From: Szymon Graczyk Date: Thu, 4 Aug 2022 14:10:53 +0200 Subject: [PATCH 5/7] Properly handle situation in which type is set to image but src is missing --- .../react-components/src/components/Avatar/Avatar.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react-components/src/components/Avatar/Avatar.tsx b/packages/react-components/src/components/Avatar/Avatar.tsx index f618aab29..1c793adc0 100644 --- a/packages/react-components/src/components/Avatar/Avatar.tsx +++ b/packages/react-components/src/components/Avatar/Avatar.tsx @@ -49,10 +49,12 @@ export const Avatar: React.FC = ({ type, withRim = false, }) => { + const isImproperImageSetup = type === 'image' && src?.length === 0; const [shouldDisplayFallbackAvatar, setShouldDisplayFallbackAvatar] = - React.useState(false); + React.useState(isImproperImageSetup); - const shouldDisplayImage = type === 'image' && !shouldDisplayFallbackAvatar; + const shouldDisplayImage = + type === 'image' && !!src && !shouldDisplayFallbackAvatar; const shouldDisplayInitials = type === 'text'; const letterCount = ['xxxsmall', 'xxsmall', 'xsmall'].includes(size) ? 1 : 2; const initials = getInitials(text, letterCount); @@ -84,6 +86,10 @@ export const Avatar: React.FC = ({ const handleError: React.ReactEventHandler | undefined = React.useCallback(() => setShouldDisplayFallbackAvatar(true), []); + React.useEffect(() => { + setShouldDisplayFallbackAvatar(isImproperImageSetup); + }, [isImproperImageSetup]); + return (
{withRim &&
} From 7f4ba5af96bcb8afcdbaa88a55b53715d3e57c31 Mon Sep 17 00:00:00 2001 From: Szymon Graczyk Date: Thu, 4 Aug 2022 17:16:52 +0200 Subject: [PATCH 6/7] Add unit tests --- .../src/components/Avatar/Avatar.spec.tsx | 139 ++++++++++++++++++ .../src/components/Avatar/Avatar.tsx | 19 ++- 2 files changed, 154 insertions(+), 4 deletions(-) create mode 100644 packages/react-components/src/components/Avatar/Avatar.spec.tsx diff --git a/packages/react-components/src/components/Avatar/Avatar.spec.tsx b/packages/react-components/src/components/Avatar/Avatar.spec.tsx new file mode 100644 index 000000000..073a49d44 --- /dev/null +++ b/packages/react-components/src/components/Avatar/Avatar.spec.tsx @@ -0,0 +1,139 @@ +import * as React from 'react'; +import { render, fireEvent, vi } from 'test-utils'; +import { Avatar, AvatarProps } from './Avatar'; + +const renderComponent = (props: AvatarProps) => { + return render(); +}; + +const baseClass = 'avatar'; + +describe(' component', () => { + it('should allow for custom CSS class', () => { + const customClass = 'custom-class'; + const { container } = renderComponent({ + type: 'text', + className: customClass, + }); + + expect(container.firstChild).toHaveClass(customClass); + }); + + it('should display initials if text is selected as type', () => { + const { getByText } = renderComponent({ + type: 'text', + text: 'John Doe', + }); + + expect(getByText('JD')).toBeVisible(); + }); + + it('should display custom color as background if provided', () => { + const customColor = 'green'; + const { container } = renderComponent({ + type: 'text', + text: 'John Doe', + color: customColor, + }); + + expect(container.firstChild).toHaveStyle( + `background-color: ${customColor}` + ); + }); + + const extraSmallSizes: AvatarProps['size'][] = [ + 'xxxsmall', + 'xxsmall', + 'xsmall', + ]; + it.each(extraSmallSizes)( + 'should display single-letter initial for %s size', + (size) => { + const { getByText } = renderComponent({ + type: 'text', + text: 'John Doe', + size, + }); + + expect(getByText('J')).toBeVisible(); + } + ); + + const smallAndAboveSizes: AvatarProps['size'][] = [ + 'small', + 'medium', + 'large', + 'xlarge', + 'xxlarge', + ]; + it.each(smallAndAboveSizes)( + 'should display two-letter initials for %s size', + (size) => { + const { getByText } = renderComponent({ + type: 'text', + text: 'John Doe', + size, + }); + + expect(getByText('JD')).toBeVisible(); + } + ); + + it('should display image if such type is specified', () => { + const imageSource = + 'https://cdn.livechatinc.com/cloud/?uri=https://livechat.s3.amazonaws.com/default/avatars/female_63.jpg'; + const { getByRole } = renderComponent({ + type: 'image', + src: imageSource, + }); + + const image = getByRole('img'); + + expect(image).toBeVisible(); + expect(image).toHaveAttribute('src', imageSource); + }); + + it('should display fallback avatar in case of missing src', () => { + const { getByTestId } = renderComponent({ + type: 'image', + }); + + expect(getByTestId(`${baseClass}__icon`)).toBeVisible(); + }); + + it('should display fallback avatar in case invalid URL (or that do not return proper image)', () => { + const { getByTestId, getByRole } = renderComponent({ + type: 'image', + src: 'https://example.com/not-a-proper-image.png', + }); + const image = getByRole('img'); + fireEvent.error(image); + + expect(getByTestId(`${baseClass}__icon`)).toBeVisible(); + }); + + const statuses: AvatarProps['status'][] = [ + 'available', + 'unavailable', + 'unknown', + ]; + it.each(statuses)('should display status icon %s as status', (status) => { + const { getByTestId } = renderComponent({ + type: 'text', + text: 'John Doe', + status, + }); + + expect(getByTestId(`${baseClass}__status`)).toBeVisible(); + }); + + it('should display rim', () => { + const { getByTestId } = renderComponent({ + type: 'text', + text: 'John Doe', + withRim: true, + }); + + expect(getByTestId(`${baseClass}__rim`)).toBeVisible(); + }); +}); diff --git a/packages/react-components/src/components/Avatar/Avatar.tsx b/packages/react-components/src/components/Avatar/Avatar.tsx index 1c793adc0..ee2aa779b 100644 --- a/packages/react-components/src/components/Avatar/Avatar.tsx +++ b/packages/react-components/src/components/Avatar/Avatar.tsx @@ -49,7 +49,7 @@ export const Avatar: React.FC = ({ type, withRim = false, }) => { - const isImproperImageSetup = type === 'image' && src?.length === 0; + const isImproperImageSetup = type === 'image' && !src; const [shouldDisplayFallbackAvatar, setShouldDisplayFallbackAvatar] = React.useState(isImproperImageSetup); @@ -66,7 +66,7 @@ export const Avatar: React.FC = ({ [styles[`${baseClass}--${shape}`]]: true, [styles[`${baseClass}--${size}`]]: true, [styles[`${baseClass}--with-rim`]]: withRim, - className, + [`${className}`]: className, }); const mergedStatusClassNames = cx( styles[`${baseClass}__status`], @@ -92,8 +92,18 @@ export const Avatar: React.FC = ({ return (
- {withRim &&
} - {status &&
} + {withRim && ( +
+ )} + {status && ( +
+ )} {shouldDisplayImage && ( = ({ )} {shouldDisplayFallbackAvatar && ( Date: Thu, 4 Aug 2022 17:17:42 +0200 Subject: [PATCH 7/7] Fix test name --- packages/react-components/src/components/Avatar/Avatar.spec.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-components/src/components/Avatar/Avatar.spec.tsx b/packages/react-components/src/components/Avatar/Avatar.spec.tsx index 073a49d44..d3ba6505d 100644 --- a/packages/react-components/src/components/Avatar/Avatar.spec.tsx +++ b/packages/react-components/src/components/Avatar/Avatar.spec.tsx @@ -8,7 +8,7 @@ const renderComponent = (props: AvatarProps) => { const baseClass = 'avatar'; -describe(' component', () => { +describe(' component', () => { it('should allow for custom CSS class', () => { const customClass = 'custom-class'; const { container } = renderComponent({