diff --git a/.changeset/soft-walls-pretend.md b/.changeset/soft-walls-pretend.md new file mode 100644 index 0000000000..51ea226d77 --- /dev/null +++ b/.changeset/soft-walls-pretend.md @@ -0,0 +1,5 @@ +--- +"@channel.io/bezier-react": patch +--- + +Add `AlphaAvatarGroup` component diff --git a/packages/bezier-react/src/components/AlphaAvatar/Avatar.tsx b/packages/bezier-react/src/components/AlphaAvatar/Avatar.tsx index b6295394fe..9d6ed82ac5 100644 --- a/packages/bezier-react/src/components/AlphaAvatar/Avatar.tsx +++ b/packages/bezier-react/src/components/AlphaAvatar/Avatar.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames' import { isEmpty } from '~/src/utils/type' +import { useAvatarGroupContext } from '~/src/components/AlphaAvatarGroup/AvatarGroup' import { SmoothCornersBox, type SmoothCornersBoxProps, @@ -11,15 +12,38 @@ import { import { Status, type StatusSize } from '~/src/components/Status' import { useTokens } from '~/src/components/ThemeProvider' -import type { AvatarProps } from './Avatar.types' +import type { AvatarProps, AvatarSize } from './Avatar.types' import defaultAvatarUrl from './assets/default-avatar.svg' import useProgressiveImage from './useProgressiveImage' import styles from './Avatar.module.scss' -const shadow: SmoothCornersBoxProps['shadow'] = { - spreadRadius: 2, - color: 'bg-white-high', +function getStatusSize(size: AvatarSize): StatusSize { + switch (size) { + case '90': + case '120': + return 'l' + default: + return 'm' + } +} + +function getShadow(size: AvatarSize): SmoothCornersBoxProps['shadow'] { + const spreadRadius = (() => { + switch (size) { + case '90': + return 3 + case '120': + return 4 + default: + return 2 + } + })() + + return { + spreadRadius, + color: 'bg-white-high', + } } export function useAvatarRadiusToken() { @@ -49,7 +73,7 @@ export const Avatar = forwardRef(function Avatar( { avatarUrl = '', fallbackUrl = defaultAvatarUrl, - size = '24', + size: sizeProps = '24', name, disabled = false, showBorder = false, @@ -61,6 +85,8 @@ export const Avatar = forwardRef(function Avatar( }, forwardedRef ) { + const avatarGroupContext = useAvatarGroupContext() + const size = avatarGroupContext?.size ?? sizeProps const loadedAvatarUrl = useProgressiveImage(avatarUrl, fallbackUrl) const AVATAR_BORDER_RADIUS = useAvatarRadiusToken() @@ -72,16 +98,6 @@ export const Avatar = forwardRef(function Avatar( return null } - const statusSize: StatusSize = (() => { - switch (size) { - case '90': - case '120': - return 'l' - default: - return 'm' - } - })() - const Contents = (() => { if (children) { return children @@ -90,7 +106,7 @@ export const Avatar = forwardRef(function Avatar( return ( ) } @@ -131,7 +147,7 @@ export const Avatar = forwardRef(function Avatar( )} disabled={!smoothCorners} borderRadius={AVATAR_BORDER_RADIUS} - shadow={showBorder ? shadow : undefined} + shadow={showBorder ? getShadow(size) : undefined} backgroundColor="bg-white-normal" backgroundImage={loadedAvatarUrl} data-testid={AVATAR_TEST_ID} diff --git a/packages/bezier-react/src/components/AlphaAvatarGroup/AlphaAvatarGroup.stories.tsx b/packages/bezier-react/src/components/AlphaAvatarGroup/AlphaAvatarGroup.stories.tsx new file mode 100644 index 0000000000..2e56165923 --- /dev/null +++ b/packages/bezier-react/src/components/AlphaAvatarGroup/AlphaAvatarGroup.stories.tsx @@ -0,0 +1,55 @@ +import React from 'react' + +import { type Meta, type StoryFn, type StoryObj } from '@storybook/react' + +import { AlphaAvatar } from '~/src/components/AlphaAvatar' + +import { AvatarGroup } from './AvatarGroup' +import { type AvatarGroupProps } from './AvatarGroup.types' +import MOCK_AVATAR_LIST from './__mocks__/avatarList' + +const meta: Meta = { + component: AvatarGroup, + argTypes: { + max: { + control: { + type: 'range', + min: 1, + max: MOCK_AVATAR_LIST.length, + step: 1, + }, + }, + spacing: { + control: { + type: 'range', + min: -50, + max: 50, + step: 1, + }, + }, + }, +} +export default meta + +const Template: StoryFn = (args) => ( + + {MOCK_AVATAR_LIST.map(({ id, avatarUrl, name }) => ( + + ))} + +) + +export const Primary: StoryObj = { + render: Template, + + args: { + max: 5, + ellipsisType: 'icon', + size: '30', + spacing: 4, + }, +} diff --git a/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.module.scss b/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.module.scss new file mode 100644 index 0000000000..b4a677eaae --- /dev/null +++ b/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.module.scss @@ -0,0 +1,53 @@ +@import '../AlphaAvatar/Avatar.module'; + +.AvatarGroup { + --b-avatar-group-spacing: 0; + --b-avatar-group-size: 0; + + position: relative; + z-index: var(--z-index-base); + display: flex; + + @each $size in $avatar-sizes { + &:where(.size-#{$size}) { + --b-avatar-group-size: #{$size}px; + } + } + + & > * + * { + margin-left: var(--b-avatar-group-spacing); + } +} + +.AvatarEllipsisIconWrapper { + position: relative; +} + +.AvatarEllipsisIcon { + position: absolute; + z-index: var(--z-index-floating); + top: 0; + right: 0; + + display: flex; + align-items: center; + justify-content: center; + + width: 100%; + height: 100%; + + outline: none; +} + +.AvatarEllipsisCountWrapper { + --b-avatar-group-ellipsis-ml: 0; + + margin-left: var(--b-avatar-group-ellipsis-ml); +} + +.AvatarEllipsisCount { + position: relative; + display: flex; + align-items: center; + height: var(--b-avatar-group-size); +} diff --git a/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.test.tsx b/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.test.tsx new file mode 100644 index 0000000000..c9acbedc8c --- /dev/null +++ b/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.test.tsx @@ -0,0 +1,93 @@ +import React from 'react' + +import { render } from '~/src/utils/test' + +import { Avatar } from '~/src/components/Avatar' + +import { AVATAR_GROUP_ELLIPSIS_ICON_TEST_ID, AvatarGroup } from './AvatarGroup' +import { type AvatarGroupProps } from './AvatarGroup.types' +import MOCK_AVATAR_LIST from './__mocks__/avatarList' + +describe('AvatarGroup', () => { + let props: AvatarGroupProps + const mockFallbackUrl = 'https://www.google.com' + + beforeEach(() => { + props = { + max: MOCK_AVATAR_LIST.length - 1, + spacing: 4, + ellipsisType: 'icon', + } + }) + + afterAll(() => { + jest.restoreAllMocks() + }) + + const renderComponent = (otherProps?: AvatarGroupProps) => + render( + + {MOCK_AVATAR_LIST.map(({ id, avatarUrl, name }) => ( + + ))} + + ) + + describe('Ellipsis type - Icon', () => { + beforeEach(() => { + props.ellipsisType = 'icon' + }) + + it('Snapshot', () => { + const { getByRole } = renderComponent() + const rendered = getByRole('group') + expect(rendered).toMatchSnapshot() + }) + + it('should render ellipsis icon when avatar count is more than max', () => { + const { getByTestId } = renderComponent() + const rendered = getByTestId(AVATAR_GROUP_ELLIPSIS_ICON_TEST_ID) + expect(rendered).toBeInTheDocument() + }) + + it('should not render ellipsis icon when avatar count is less than max', () => { + props.max = MOCK_AVATAR_LIST.length + const { queryByTestId } = renderComponent() + const rendered = queryByTestId(AVATAR_GROUP_ELLIPSIS_ICON_TEST_ID) + expect(rendered).not.toBeInTheDocument() + }) + }) + + describe('Ellipsis type - Count', () => { + beforeEach(() => { + props.ellipsisType = 'count' + }) + + it('Snapshot', () => { + const { getByRole } = renderComponent() + const rendered = getByRole('group') + expect(rendered).toMatchSnapshot() + }) + + it('should render ellipsis count when avatar count is more than max', () => { + const { getByText } = renderComponent() + const rendered = getByText('+1') + expect(rendered).toBeInTheDocument() + }) + + it('should not render ellipsis count when avatar count is less than max', () => { + props.max = MOCK_AVATAR_LIST.length + const { queryByText } = renderComponent() + const rendered = queryByText('+1') + expect(rendered).not.toBeInTheDocument() + }) + }) +}) diff --git a/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.tsx b/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.tsx new file mode 100644 index 0000000000..c6048f3022 --- /dev/null +++ b/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.tsx @@ -0,0 +1,229 @@ +import React, { forwardRef, useCallback, useMemo } from 'react' + +import { MoreIcon } from '@channel.io/bezier-icons' +import classNames from 'classnames' + +import { isLastIndex } from '~/src/utils/array' +import { createContext } from '~/src/utils/react' +import { px } from '~/src/utils/style' + +import { + type AlphaAvatarProps, + type AlphaAvatarSize, + useAlphaAvatarRadiusToken, +} from '~/src/components/AlphaAvatar' +import { Icon } from '~/src/components/Icon' +import { SmoothCornersBox } from '~/src/components/SmoothCornersBox' +import { Text } from '~/src/components/Text' + +import { + type AvatarGroupContextValue, + type AvatarGroupProps, +} from './AvatarGroup.types' + +import styles from './AvatarGroup.module.scss' + +const [AvatarGroupContextProvider, useAvatarGroupContext] = createContext< + AvatarGroupContextValue | undefined +>(undefined) + +export { useAvatarGroupContext } + +const MAX_AVATAR_LIST_COUNT = 99 +const AVATAR_GROUP_DEFAULT_SPACING = 4 +export const AVATAR_GROUP_ELLIPSIS_ICON_TEST_ID = + 'bezier-avatar-group-ellipsis-icon' + +function getRestAvatarListCountText(count: number, max: number) { + const restCount = count - max + return `+${restCount > MAX_AVATAR_LIST_COUNT ? MAX_AVATAR_LIST_COUNT : restCount}` +} + +// TODO: Not specified +function getProperIconSize(size: AlphaAvatarSize) { + return ( + { + 16: 'xxs', + 20: 'xxs', + 24: 'xs', + 30: 's', + 36: 'm', + 42: 'm', + 48: 'l', + 72: 'l', + 90: 'l', + 120: 'l', + } as const + )[size] +} + +// TODO: Not specified +function getProperTypoSize(size: AlphaAvatarSize) { + return ( + { + 16: '12', + 20: '12', + 24: '13', + 30: '15', + 36: '16', + 42: '18', + 48: '24', + 72: '24', + 90: '24', + 120: '24', + } as const + )[size] +} + +/** + * `AvatarGroup` is a component for grouping `Avatar` components + * @example + * + * ```tsx + * + * + * + * + * + * ``` + */ +export const AvatarGroup = forwardRef( + function AvatarGroup( + { + max = 5, + size = '24', + spacing = AVATAR_GROUP_DEFAULT_SPACING, + ellipsisType = 'icon', + style, + className, + children, + ...rest + }, + forwardedRef + ) { + const AVATAR_BORDER_RADIUS = useAlphaAvatarRadiusToken() + const avatarListCount = React.Children.count(children) + + const renderAvatarElement = useCallback( + (avatar: React.ReactElement) => { + const key = + avatar.key ?? `${avatar.props.name}-${avatar.props.avatarUrl}` + const shouldShowBorder = max > 1 && avatarListCount > 1 && spacing < 0 + const showBorder = avatar.props.showBorder || shouldShowBorder + return React.cloneElement(avatar, { key, showBorder }) + }, + [avatarListCount, max, spacing] + ) + + const AvatarListComponent = useMemo(() => { + return React.Children.toArray(children) + .slice(0, max) + .map((avatar, index, arr) => { + if (!React.isValidElement(avatar)) { + return null + } + + const AvatarElement = renderAvatarElement(avatar) + + if (!isLastIndex(arr, index) || avatarListCount <= max) { + return AvatarElement + } + + if (ellipsisType === 'icon') { + return ( +
+ + + + {AvatarElement} +
+ ) + } + + if (ellipsisType === 'count') { + return ( + + {AvatarElement} +
+ + {getRestAvatarListCountText(avatarListCount, max)} + +
+
+ ) + } + + return null + }) + }, [ + avatarListCount, + max, + children, + renderAvatarElement, + ellipsisType, + AVATAR_BORDER_RADIUS, + size, + spacing, + ]) + + return ( + ({ + size, + }), + [size] + )} + > +
+ {AvatarListComponent} +
+
+ ) + } +) diff --git a/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.types.ts b/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.types.ts new file mode 100644 index 0000000000..1070f63cb7 --- /dev/null +++ b/packages/bezier-react/src/components/AlphaAvatarGroup/AvatarGroup.types.ts @@ -0,0 +1,43 @@ +import type { + BezierComponentProps, + ChildrenProps, + SizeProps, +} from '~/src/types/props' + +import { + type AlphaAvatarProps, + type AlphaAvatarSize, +} from '~/src/components/AlphaAvatar' + +type AvatarGroupEllipsisType = 'icon' | 'count' + +interface AvatarGroupOwnProps { + /** + * Maximum number of avatars to display. + * If the number of avatars exceeds this number, ellipsis will be displayed. + * @default 5 + */ + max: number + + /** + * Spacing between the avatars. + * Spacing could be negative, which will make the avatars overlap each other. + * @default 4 + */ + spacing?: number + + /** + * Controls how the ellipsis is displayed. + * @default 'icon' + */ + ellipsisType?: AvatarGroupEllipsisType +} + +export interface AvatarGroupContextValue + extends Pick {} + +export interface AvatarGroupProps + extends BezierComponentProps<'div'>, + ChildrenProps, + SizeProps, + AvatarGroupOwnProps {} diff --git a/packages/bezier-react/src/components/AlphaAvatarGroup/__mocks__/avatarList.ts b/packages/bezier-react/src/components/AlphaAvatarGroup/__mocks__/avatarList.ts new file mode 100644 index 0000000000..edeabfa982 --- /dev/null +++ b/packages/bezier-react/src/components/AlphaAvatarGroup/__mocks__/avatarList.ts @@ -0,0 +1,39 @@ +const MOCK_AVATAR_LIST = [ + { + id: 1, + avatarUrl: 'https://bit.ly/code-beast', + name: 'Christian Nwamba', + }, + { + id: 2, + avatarUrl: 'https://bit.ly/tioluwani-kolawole', + name: 'Kola Tioluwani', + }, + { + id: 3, + avatarUrl: 'https://bit.ly/kent-c-dodds', + name: 'Kent Dodds', + }, + { + id: 4, + avatarUrl: 'https://bit.ly/ryan-florence', + name: 'Ryan Florence', + }, + { + id: 5, + avatarUrl: 'https://bit.ly/dan-abramov', + name: 'Dan Abrahmov', + }, + { + id: 6, + avatarUrl: 'https://bit.ly/prosper-baba', + name: 'Prosper Otemuyiwa', + }, + { + id: 7, + avatarUrl: 'https://bit.ly/sage-adebayo', + name: 'Segun Adebayo', + }, +] + +export default MOCK_AVATAR_LIST diff --git a/packages/bezier-react/src/components/AlphaAvatarGroup/__snapshots__/AvatarGroup.test.tsx.snap b/packages/bezier-react/src/components/AlphaAvatarGroup/__snapshots__/AvatarGroup.test.tsx.snap new file mode 100644 index 0000000000..b602f31cf1 --- /dev/null +++ b/packages/bezier-react/src/components/AlphaAvatarGroup/__snapshots__/AvatarGroup.test.tsx.snap @@ -0,0 +1,215 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AvatarGroup Ellipsis type - Count Snapshot 1`] = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +1 + +
+
+`; + +exports[`AvatarGroup Ellipsis type - Icon Snapshot 1`] = ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + +
+
+
+
+
+
+`; diff --git a/packages/bezier-react/src/components/AlphaAvatarGroup/index.ts b/packages/bezier-react/src/components/AlphaAvatarGroup/index.ts new file mode 100644 index 0000000000..1471a4efb4 --- /dev/null +++ b/packages/bezier-react/src/components/AlphaAvatarGroup/index.ts @@ -0,0 +1,2 @@ +export { AvatarGroup as AlphaAvatarGroup } from './AvatarGroup' +export { type AvatarGroupProps as AlphaAvatarGroupProps } from './AvatarGroup.types' diff --git a/packages/bezier-react/src/index.ts b/packages/bezier-react/src/index.ts index 2c291ecea5..c2c07a52cc 100644 --- a/packages/bezier-react/src/index.ts +++ b/packages/bezier-react/src/index.ts @@ -6,6 +6,7 @@ export { tokens } from '@channel.io/bezier-tokens' /* ------------------------------- COMPONENTS ------------------------------- */ export * from '~/src/components/AlphaAvatar' +export * from '~/src/components/AlphaAvatarGroup' export * from '~/src/components/AlphaDialogPrimitive' export * from '~/src/components/AlphaTooltipPrimitive' export * from '~/src/components/AppProvider'