@channel.io/bezier-react 2.0.2 → 2.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/dist/cjs/components/AlphaAvatar/Avatar.js +121 -0
  2. package/dist/cjs/components/AlphaAvatar/Avatar.js.map +1 -0
  3. package/dist/cjs/components/AlphaAvatar/Avatar.module.scss.js +8 -0
  4. package/dist/cjs/components/AlphaAvatar/Avatar.module.scss.js.map +1 -0
  5. package/dist/cjs/components/AlphaAvatar/assets/default-avatar.svg.js +8 -0
  6. package/dist/cjs/components/AlphaAvatar/assets/default-avatar.svg.js.map +1 -0
  7. package/dist/cjs/components/AlphaAvatar/useProgressiveImage.js +48 -0
  8. package/dist/cjs/components/AlphaAvatar/useProgressiveImage.js.map +1 -0
  9. package/dist/cjs/components/AlphaAvatarGroup/AvatarGroup.js +153 -0
  10. package/dist/cjs/components/AlphaAvatarGroup/AvatarGroup.js.map +1 -0
  11. package/dist/cjs/components/AlphaAvatarGroup/AvatarGroup.module.scss.js +8 -0
  12. package/dist/cjs/components/AlphaAvatarGroup/AvatarGroup.module.scss.js.map +1 -0
  13. package/dist/cjs/index.js +10 -5
  14. package/dist/cjs/index.js.map +1 -1
  15. package/dist/cjs/styles.css +1 -1
  16. package/dist/esm/components/AlphaAvatar/Avatar.mjs +115 -0
  17. package/dist/esm/components/AlphaAvatar/Avatar.mjs.map +1 -0
  18. package/dist/esm/components/AlphaAvatar/Avatar.module.scss.mjs +4 -0
  19. package/dist/esm/components/AlphaAvatar/Avatar.module.scss.mjs.map +1 -0
  20. package/dist/esm/components/AlphaAvatar/assets/default-avatar.svg.mjs +4 -0
  21. package/dist/esm/components/AlphaAvatar/assets/default-avatar.svg.mjs.map +1 -0
  22. package/dist/esm/components/AlphaAvatar/useProgressiveImage.mjs +44 -0
  23. package/dist/esm/components/AlphaAvatar/useProgressiveImage.mjs.map +1 -0
  24. package/dist/esm/components/AlphaAvatarGroup/AvatarGroup.mjs +149 -0
  25. package/dist/esm/components/AlphaAvatarGroup/AvatarGroup.mjs.map +1 -0
  26. package/dist/esm/components/AlphaAvatarGroup/AvatarGroup.module.scss.mjs +4 -0
  27. package/dist/esm/components/AlphaAvatarGroup/AvatarGroup.module.scss.mjs.map +1 -0
  28. package/dist/esm/index.mjs +2 -0
  29. package/dist/esm/index.mjs.map +1 -1
  30. package/dist/esm/styles.css +1 -1
  31. package/dist/types/components/AlphaAvatar/Avatar.d.ts +22 -0
  32. package/dist/types/components/AlphaAvatar/Avatar.d.ts.map +1 -0
  33. package/dist/types/components/AlphaAvatar/Avatar.types.d.ts +42 -0
  34. package/dist/types/components/AlphaAvatar/Avatar.types.d.ts.map +1 -0
  35. package/dist/types/components/AlphaAvatar/index.d.ts +3 -0
  36. package/dist/types/components/AlphaAvatar/index.d.ts.map +1 -0
  37. package/dist/types/components/AlphaAvatar/useProgressiveImage.d.ts +2 -0
  38. package/dist/types/components/AlphaAvatar/useProgressiveImage.d.ts.map +1 -0
  39. package/dist/types/components/AlphaAvatarGroup/AvatarGroup.d.ts +24 -0
  40. package/dist/types/components/AlphaAvatarGroup/AvatarGroup.d.ts.map +1 -0
  41. package/dist/types/components/AlphaAvatarGroup/AvatarGroup.types.d.ts +28 -0
  42. package/dist/types/components/AlphaAvatarGroup/AvatarGroup.types.d.ts.map +1 -0
  43. package/dist/types/components/AlphaAvatarGroup/index.d.ts +3 -0
  44. package/dist/types/components/AlphaAvatarGroup/index.d.ts.map +1 -0
  45. package/dist/types/index.d.ts +2 -0
  46. package/dist/types/index.d.ts.map +1 -1
  47. package/package.json +2 -2
  48. package/src/components/AlphaAvatar/AlphaAvatar.stories.tsx +63 -0
  49. package/src/components/AlphaAvatar/Avatar.module.scss +54 -0
  50. package/src/components/AlphaAvatar/Avatar.test.tsx +111 -0
  51. package/src/components/AlphaAvatar/Avatar.tsx +159 -0
  52. package/src/components/AlphaAvatar/Avatar.types.ts +64 -0
  53. package/src/components/AlphaAvatar/__snapshots__/Avatar.test.tsx.snap +93 -0
  54. package/src/components/AlphaAvatar/assets/default-avatar.svg +11 -0
  55. package/src/components/AlphaAvatar/index.ts +8 -0
  56. package/src/components/AlphaAvatar/useProgressiveImage.test.ts +96 -0
  57. package/src/components/AlphaAvatar/useProgressiveImage.ts +60 -0
  58. package/src/components/AlphaAvatarGroup/AlphaAvatarGroup.stories.tsx +55 -0
  59. package/src/components/AlphaAvatarGroup/AvatarGroup.module.scss +53 -0
  60. package/src/components/AlphaAvatarGroup/AvatarGroup.test.tsx +93 -0
  61. package/src/components/AlphaAvatarGroup/AvatarGroup.tsx +229 -0
  62. package/src/components/AlphaAvatarGroup/AvatarGroup.types.ts +43 -0
  63. package/src/components/AlphaAvatarGroup/__mocks__/avatarList.ts +39 -0
  64. package/src/components/AlphaAvatarGroup/__snapshots__/AvatarGroup.test.tsx.snap +215 -0
  65. package/src/components/AlphaAvatarGroup/index.ts +2 -0
  66. package/src/index.ts +2 -0
@@ -0,0 +1,93 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`Avatar > Snapshot 1`] = `
4
+ <div
5
+ aria-label="Name"
6
+ class="SmoothCornersBox AvatarImage"
7
+ data-state="disabled"
8
+ data-testid="bezier-avatar"
9
+ style="--b-smooth-corners-box-border-radius: 42%; --b-smooth-corners-box-shadow-blur-radius: 0px; --b-smooth-corners-box-shadow-spread-radius: 0px; --b-smooth-corners-box-padding: 0px; --b-smooth-corners-box-margin: 0px; --b-smooth-corners-box-background-color: var(--bg-white-normal); --b-smooth-corners-box-background-image: url(https://www.google.com);"
10
+ />
11
+ `;
12
+
13
+ exports[`Avatar > renders border style 1`] = `
14
+ <div
15
+ aria-label="Name"
16
+ class="SmoothCornersBox AvatarImage bordered"
17
+ data-state="disabled"
18
+ data-testid="bezier-avatar"
19
+ style="--b-smooth-corners-box-border-radius: 42%; --b-smooth-corners-box-shadow-blur-radius: 0px; --b-smooth-corners-box-shadow-spread-radius: 2px; --b-smooth-corners-box-shadow-color: var(--bg-white-high); --b-smooth-corners-box-padding: 4px; --b-smooth-corners-box-margin: 0px; --b-smooth-corners-box-background-color: var(--bg-white-normal); --b-smooth-corners-box-background-image: url(https://www.google.com);"
20
+ />
21
+ `;
22
+
23
+ exports[`Avatar > should have right -2px, bottom -2px style on StatusWrapper 1`] = `
24
+ <div
25
+ class="StatusWrapper"
26
+ data-testid="bezier-status-wrapper"
27
+ >
28
+ <div
29
+ class="Status size-m"
30
+ style="--b-status-bg-color: var(--bgtxt-green-normal);"
31
+ />
32
+ </div>
33
+ `;
34
+
35
+ exports[`Avatar > should have right 2px, bottom 2px style on StatusWrapper when show border 1`] = `
36
+ <div
37
+ class="StatusWrapper"
38
+ data-testid="bezier-status-wrapper"
39
+ >
40
+ <div
41
+ class="Status size-m"
42
+ style="--b-status-bg-color: var(--bgtxt-green-normal);"
43
+ />
44
+ </div>
45
+ `;
46
+
47
+ exports[`Avatar > should have right 4px, bottom 4px style on StatusWrapper when size grater then 72 1`] = `
48
+ <div
49
+ class="StatusWrapper"
50
+ data-testid="bezier-status-wrapper"
51
+ >
52
+ <div
53
+ class="Status size-m"
54
+ style="--b-status-bg-color: var(--bgtxt-green-normal);"
55
+ />
56
+ </div>
57
+ `;
58
+
59
+ exports[`Avatar > should have right 4px, bottom 4px style on StatusWrapper when size grater then 90 1`] = `
60
+ <div
61
+ class="StatusWrapper"
62
+ data-testid="bezier-status-wrapper"
63
+ >
64
+ <div
65
+ class="Status size-l"
66
+ style="--b-status-bg-color: var(--bgtxt-green-normal);"
67
+ />
68
+ </div>
69
+ `;
70
+
71
+ exports[`Avatar > should have right 8px, bottom 8px style on StatusWrapper when size grater then 72 and show border 1`] = `
72
+ <div
73
+ class="StatusWrapper"
74
+ data-testid="bezier-status-wrapper"
75
+ >
76
+ <div
77
+ class="Status size-m"
78
+ style="--b-status-bg-color: var(--bgtxt-green-normal);"
79
+ />
80
+ </div>
81
+ `;
82
+
83
+ exports[`Avatar > should have right 8px, bottom 8px style on StatusWrapper when size grater then 90 and show border 1`] = `
84
+ <div
85
+ class="StatusWrapper"
86
+ data-testid="bezier-status-wrapper"
87
+ >
88
+ <div
89
+ class="Status size-l"
90
+ style="--b-status-bg-color: var(--bgtxt-green-normal);"
91
+ />
92
+ </div>
93
+ `;
@@ -0,0 +1,11 @@
1
+ <svg width="60" height="60" viewBox="0 0 60 60" fill="none" xmlns="http://www.w3.org/2000/svg">
2
+ <g clip-path="url(#clip0)">
3
+ <rect width="60" height="60" fill="#cfccfb"/>
4
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M41 23C41 29.0744 36.0744 34 30 34C23.9256 34 19 29.0744 19 23C19 16.9256 23.9256 12 30 12C36.0744 12 41 16.9256 41 23ZM30 37C16.1929 37 5 48.1929 5 62V90H55V62C55 48.1929 43.8071 37 30 37Z" fill="white" fill-opacity="0.4"/>
5
+ </g>
6
+ <defs>
7
+ <clipPath id="clip0">
8
+ <rect width="60" height="60" fill="white"/>
9
+ </clipPath>
10
+ </defs>
11
+ </svg>
@@ -0,0 +1,8 @@
1
+ export {
2
+ Avatar as AlphaAvatar,
3
+ useAvatarRadiusToken as useAlphaAvatarRadiusToken,
4
+ } from './Avatar'
5
+ export type {
6
+ AvatarProps as AlphaAvatarProps,
7
+ AvatarSize as AlphaAvatarSize,
8
+ } from './Avatar.types'
@@ -0,0 +1,96 @@
1
+ import { type RenderHookResult, waitFor } from '@testing-library/react'
2
+
3
+ import { renderHook } from '~/src/utils/test'
4
+
5
+ import useProgressiveImage from './useProgressiveImage'
6
+
7
+ const DELAY = 300
8
+ const TIME_OUT = DELAY + 1000
9
+ const IMAGE_URL_1 = 'image-url-1'
10
+ const IMAGE_URL_2 = 'image-url-2'
11
+ const IMAGE_URL_3 = 'image-url-3'
12
+ const FALLBACK_URL = 'fallback-url'
13
+
14
+ describe('useProgressiveImage ', () => {
15
+ let rendered: RenderHookResult<
16
+ string,
17
+ { imageUrl: string; fallbackUrl: string }
18
+ >
19
+ const originalGlobalImage = window.Image
20
+
21
+ beforeAll(() => {
22
+ ;(window.Image as any) = class MockImage {
23
+ onload = () => {}
24
+ src = ''
25
+ constructor() {
26
+ setTimeout(() => {
27
+ this.onload()
28
+ }, DELAY)
29
+ return this
30
+ }
31
+ }
32
+ })
33
+
34
+ afterAll(() => {
35
+ window.Image = originalGlobalImage
36
+ })
37
+
38
+ beforeEach(() => {
39
+ rendered = renderHook(
40
+ ({ imageUrl, fallbackUrl }) => useProgressiveImage(imageUrl, fallbackUrl),
41
+ {
42
+ initialProps: {
43
+ imageUrl: IMAGE_URL_1,
44
+ fallbackUrl: FALLBACK_URL,
45
+ },
46
+ }
47
+ )
48
+ })
49
+
50
+ it('should return fallback url at first', () => {
51
+ const { result } = rendered
52
+
53
+ expect(result.current).toStrictEqual(FALLBACK_URL)
54
+ })
55
+
56
+ it('should return image url after it loads', async () => {
57
+ const { result } = rendered
58
+
59
+ await waitFor(() => expect(result.current).toStrictEqual(IMAGE_URL_1), {
60
+ timeout: TIME_OUT,
61
+ })
62
+ })
63
+
64
+ it('should update image url when hook is rendered with different url', async () => {
65
+ const { result, rerender } = rendered
66
+
67
+ rerender({
68
+ imageUrl: IMAGE_URL_2,
69
+ fallbackUrl: FALLBACK_URL,
70
+ })
71
+
72
+ await waitFor(() => expect(result.current).toStrictEqual(IMAGE_URL_2), {
73
+ timeout: TIME_OUT,
74
+ })
75
+ })
76
+
77
+ it('should loads image url immediately when rerendered', async () => {
78
+ const { rerender, result } = rendered
79
+
80
+ rerender({
81
+ imageUrl: IMAGE_URL_3,
82
+ fallbackUrl: FALLBACK_URL,
83
+ })
84
+
85
+ await waitFor(() => expect(result.current).toStrictEqual(IMAGE_URL_3), {
86
+ timeout: TIME_OUT,
87
+ })
88
+
89
+ rerender({
90
+ imageUrl: IMAGE_URL_3,
91
+ fallbackUrl: FALLBACK_URL,
92
+ })
93
+
94
+ expect(result.current).toStrictEqual(IMAGE_URL_3)
95
+ })
96
+ })
@@ -0,0 +1,60 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ interface CachedImage {
4
+ src: string
5
+ isLoaded: boolean
6
+ }
7
+
8
+ const imageCache = new Map<string, CachedImage>()
9
+
10
+ function getCachedImage(src: string) {
11
+ const cachedImage = imageCache.get(src)
12
+ if (!cachedImage) {
13
+ return null
14
+ }
15
+ return cachedImage
16
+ }
17
+
18
+ export default function useProgressiveImage(src: string, defaultSrc: string) {
19
+ const [source, setSource] = useState<CachedImage | null>(() =>
20
+ getCachedImage(src)
21
+ )
22
+
23
+ useEffect(
24
+ function updateSource() {
25
+ if (source?.src === src) {
26
+ return undefined
27
+ }
28
+
29
+ const cachedImage = getCachedImage(src)
30
+
31
+ if (cachedImage?.isLoaded) {
32
+ setSource(cachedImage)
33
+ return undefined
34
+ }
35
+
36
+ const image = new Image()
37
+ image.src = src
38
+ image.onload = loadImage(true)
39
+ image.onerror = loadImage(false)
40
+
41
+ function loadImage(isLoaded: boolean) {
42
+ return () => {
43
+ const loadedImage = {
44
+ src,
45
+ isLoaded,
46
+ }
47
+ setSource(loadedImage)
48
+ imageCache.set(src, loadedImage)
49
+ }
50
+ }
51
+ },
52
+ [src, source]
53
+ )
54
+
55
+ if (!source || !source.isLoaded) {
56
+ return defaultSrc
57
+ }
58
+
59
+ return source.src
60
+ }
@@ -0,0 +1,55 @@
1
+ import React from 'react'
2
+
3
+ import { type Meta, type StoryFn, type StoryObj } from '@storybook/react'
4
+
5
+ import { AlphaAvatar } from '~/src/components/AlphaAvatar'
6
+
7
+ import { AvatarGroup } from './AvatarGroup'
8
+ import { type AvatarGroupProps } from './AvatarGroup.types'
9
+ import MOCK_AVATAR_LIST from './__mocks__/avatarList'
10
+
11
+ const meta: Meta<typeof AvatarGroup> = {
12
+ component: AvatarGroup,
13
+ argTypes: {
14
+ max: {
15
+ control: {
16
+ type: 'range',
17
+ min: 1,
18
+ max: MOCK_AVATAR_LIST.length,
19
+ step: 1,
20
+ },
21
+ },
22
+ spacing: {
23
+ control: {
24
+ type: 'range',
25
+ min: -50,
26
+ max: 50,
27
+ step: 1,
28
+ },
29
+ },
30
+ },
31
+ }
32
+ export default meta
33
+
34
+ const Template: StoryFn<AvatarGroupProps> = (args) => (
35
+ <AvatarGroup {...args}>
36
+ {MOCK_AVATAR_LIST.map(({ id, avatarUrl, name }) => (
37
+ <AlphaAvatar
38
+ key={id}
39
+ avatarUrl={avatarUrl}
40
+ name={name}
41
+ />
42
+ ))}
43
+ </AvatarGroup>
44
+ )
45
+
46
+ export const Primary: StoryObj<AvatarGroupProps> = {
47
+ render: Template,
48
+
49
+ args: {
50
+ max: 5,
51
+ ellipsisType: 'icon',
52
+ size: '30',
53
+ spacing: 4,
54
+ },
55
+ }
@@ -0,0 +1,53 @@
1
+ @import '../AlphaAvatar/Avatar.module';
2
+
3
+ .AvatarGroup {
4
+ --b-avatar-group-spacing: 0;
5
+ --b-avatar-group-size: 0;
6
+
7
+ position: relative;
8
+ z-index: var(--z-index-base);
9
+ display: flex;
10
+
11
+ @each $size in $avatar-sizes {
12
+ &:where(.size-#{$size}) {
13
+ --b-avatar-group-size: #{$size}px;
14
+ }
15
+ }
16
+
17
+ & > * + * {
18
+ margin-left: var(--b-avatar-group-spacing);
19
+ }
20
+ }
21
+
22
+ .AvatarEllipsisIconWrapper {
23
+ position: relative;
24
+ }
25
+
26
+ .AvatarEllipsisIcon {
27
+ position: absolute;
28
+ z-index: var(--z-index-floating);
29
+ top: 0;
30
+ right: 0;
31
+
32
+ display: flex;
33
+ align-items: center;
34
+ justify-content: center;
35
+
36
+ width: 100%;
37
+ height: 100%;
38
+
39
+ outline: none;
40
+ }
41
+
42
+ .AvatarEllipsisCountWrapper {
43
+ --b-avatar-group-ellipsis-ml: 0;
44
+
45
+ margin-left: var(--b-avatar-group-ellipsis-ml);
46
+ }
47
+
48
+ .AvatarEllipsisCount {
49
+ position: relative;
50
+ display: flex;
51
+ align-items: center;
52
+ height: var(--b-avatar-group-size);
53
+ }
@@ -0,0 +1,93 @@
1
+ import React from 'react'
2
+
3
+ import { render } from '~/src/utils/test'
4
+
5
+ import { Avatar } from '~/src/components/Avatar'
6
+
7
+ import { AVATAR_GROUP_ELLIPSIS_ICON_TEST_ID, AvatarGroup } from './AvatarGroup'
8
+ import { type AvatarGroupProps } from './AvatarGroup.types'
9
+ import MOCK_AVATAR_LIST from './__mocks__/avatarList'
10
+
11
+ describe('AvatarGroup', () => {
12
+ let props: AvatarGroupProps
13
+ const mockFallbackUrl = 'https://www.google.com'
14
+
15
+ beforeEach(() => {
16
+ props = {
17
+ max: MOCK_AVATAR_LIST.length - 1,
18
+ spacing: 4,
19
+ ellipsisType: 'icon',
20
+ }
21
+ })
22
+
23
+ afterAll(() => {
24
+ jest.restoreAllMocks()
25
+ })
26
+
27
+ const renderComponent = (otherProps?: AvatarGroupProps) =>
28
+ render(
29
+ <AvatarGroup
30
+ {...props}
31
+ {...otherProps}
32
+ >
33
+ {MOCK_AVATAR_LIST.map(({ id, avatarUrl, name }) => (
34
+ <Avatar
35
+ key={id}
36
+ avatarUrl={avatarUrl}
37
+ fallbackUrl={mockFallbackUrl}
38
+ name={name}
39
+ />
40
+ ))}
41
+ </AvatarGroup>
42
+ )
43
+
44
+ describe('Ellipsis type - Icon', () => {
45
+ beforeEach(() => {
46
+ props.ellipsisType = 'icon'
47
+ })
48
+
49
+ it('Snapshot', () => {
50
+ const { getByRole } = renderComponent()
51
+ const rendered = getByRole('group')
52
+ expect(rendered).toMatchSnapshot()
53
+ })
54
+
55
+ it('should render ellipsis icon when avatar count is more than max', () => {
56
+ const { getByTestId } = renderComponent()
57
+ const rendered = getByTestId(AVATAR_GROUP_ELLIPSIS_ICON_TEST_ID)
58
+ expect(rendered).toBeInTheDocument()
59
+ })
60
+
61
+ it('should not render ellipsis icon when avatar count is less than max', () => {
62
+ props.max = MOCK_AVATAR_LIST.length
63
+ const { queryByTestId } = renderComponent()
64
+ const rendered = queryByTestId(AVATAR_GROUP_ELLIPSIS_ICON_TEST_ID)
65
+ expect(rendered).not.toBeInTheDocument()
66
+ })
67
+ })
68
+
69
+ describe('Ellipsis type - Count', () => {
70
+ beforeEach(() => {
71
+ props.ellipsisType = 'count'
72
+ })
73
+
74
+ it('Snapshot', () => {
75
+ const { getByRole } = renderComponent()
76
+ const rendered = getByRole('group')
77
+ expect(rendered).toMatchSnapshot()
78
+ })
79
+
80
+ it('should render ellipsis count when avatar count is more than max', () => {
81
+ const { getByText } = renderComponent()
82
+ const rendered = getByText('+1')
83
+ expect(rendered).toBeInTheDocument()
84
+ })
85
+
86
+ it('should not render ellipsis count when avatar count is less than max', () => {
87
+ props.max = MOCK_AVATAR_LIST.length
88
+ const { queryByText } = renderComponent()
89
+ const rendered = queryByText('+1')
90
+ expect(rendered).not.toBeInTheDocument()
91
+ })
92
+ })
93
+ })
@@ -0,0 +1,229 @@
1
+ import React, { forwardRef, useCallback, useMemo } from 'react'
2
+
3
+ import { MoreIcon } from '@channel.io/bezier-icons'
4
+ import classNames from 'classnames'
5
+
6
+ import { isLastIndex } from '~/src/utils/array'
7
+ import { createContext } from '~/src/utils/react'
8
+ import { px } from '~/src/utils/style'
9
+
10
+ import {
11
+ type AlphaAvatarProps,
12
+ type AlphaAvatarSize,
13
+ useAlphaAvatarRadiusToken,
14
+ } from '~/src/components/AlphaAvatar'
15
+ import { Icon } from '~/src/components/Icon'
16
+ import { SmoothCornersBox } from '~/src/components/SmoothCornersBox'
17
+ import { Text } from '~/src/components/Text'
18
+
19
+ import {
20
+ type AvatarGroupContextValue,
21
+ type AvatarGroupProps,
22
+ } from './AvatarGroup.types'
23
+
24
+ import styles from './AvatarGroup.module.scss'
25
+
26
+ const [AvatarGroupContextProvider, useAvatarGroupContext] = createContext<
27
+ AvatarGroupContextValue | undefined
28
+ >(undefined)
29
+
30
+ export { useAvatarGroupContext }
31
+
32
+ const MAX_AVATAR_LIST_COUNT = 99
33
+ const AVATAR_GROUP_DEFAULT_SPACING = 4
34
+ export const AVATAR_GROUP_ELLIPSIS_ICON_TEST_ID =
35
+ 'bezier-avatar-group-ellipsis-icon'
36
+
37
+ function getRestAvatarListCountText(count: number, max: number) {
38
+ const restCount = count - max
39
+ return `+${restCount > MAX_AVATAR_LIST_COUNT ? MAX_AVATAR_LIST_COUNT : restCount}`
40
+ }
41
+
42
+ // TODO: Not specified
43
+ function getProperIconSize(size: AlphaAvatarSize) {
44
+ return (
45
+ {
46
+ 16: 'xxs',
47
+ 20: 'xxs',
48
+ 24: 'xs',
49
+ 30: 's',
50
+ 36: 'm',
51
+ 42: 'm',
52
+ 48: 'l',
53
+ 72: 'l',
54
+ 90: 'l',
55
+ 120: 'l',
56
+ } as const
57
+ )[size]
58
+ }
59
+
60
+ // TODO: Not specified
61
+ function getProperTypoSize(size: AlphaAvatarSize) {
62
+ return (
63
+ {
64
+ 16: '12',
65
+ 20: '12',
66
+ 24: '13',
67
+ 30: '15',
68
+ 36: '16',
69
+ 42: '18',
70
+ 48: '24',
71
+ 72: '24',
72
+ 90: '24',
73
+ 120: '24',
74
+ } as const
75
+ )[size]
76
+ }
77
+
78
+ /**
79
+ * `AvatarGroup` is a component for grouping `Avatar` components
80
+ * @example
81
+ *
82
+ * ```tsx
83
+ * <AvatarGroup
84
+ * max={2}
85
+ * size="24"
86
+ * spacing={4}
87
+ * ellipsisType="icon"
88
+ * >
89
+ * <Avatar />
90
+ * <Avatar />
91
+ * <Avatar />
92
+ * </AvatarGroup>
93
+ * ```
94
+ */
95
+ export const AvatarGroup = forwardRef<HTMLDivElement, AvatarGroupProps>(
96
+ function AvatarGroup(
97
+ {
98
+ max = 5,
99
+ size = '24',
100
+ spacing = AVATAR_GROUP_DEFAULT_SPACING,
101
+ ellipsisType = 'icon',
102
+ style,
103
+ className,
104
+ children,
105
+ ...rest
106
+ },
107
+ forwardedRef
108
+ ) {
109
+ const AVATAR_BORDER_RADIUS = useAlphaAvatarRadiusToken()
110
+ const avatarListCount = React.Children.count(children)
111
+
112
+ const renderAvatarElement = useCallback(
113
+ (avatar: React.ReactElement<AlphaAvatarProps>) => {
114
+ const key =
115
+ avatar.key ?? `${avatar.props.name}-${avatar.props.avatarUrl}`
116
+ const shouldShowBorder = max > 1 && avatarListCount > 1 && spacing < 0
117
+ const showBorder = avatar.props.showBorder || shouldShowBorder
118
+ return React.cloneElement(avatar, { key, showBorder })
119
+ },
120
+ [avatarListCount, max, spacing]
121
+ )
122
+
123
+ const AvatarListComponent = useMemo(() => {
124
+ return React.Children.toArray(children)
125
+ .slice(0, max)
126
+ .map((avatar, index, arr) => {
127
+ if (!React.isValidElement<AlphaAvatarProps>(avatar)) {
128
+ return null
129
+ }
130
+
131
+ const AvatarElement = renderAvatarElement(avatar)
132
+
133
+ if (!isLastIndex(arr, index) || avatarListCount <= max) {
134
+ return AvatarElement
135
+ }
136
+
137
+ if (ellipsisType === 'icon') {
138
+ return (
139
+ <div
140
+ key="ellipsis"
141
+ className={styles.AvatarEllipsisIconWrapper}
142
+ data-testid={AVATAR_GROUP_ELLIPSIS_ICON_TEST_ID}
143
+ >
144
+ <SmoothCornersBox
145
+ borderRadius={AVATAR_BORDER_RADIUS}
146
+ backgroundColor="bgtxt-absolute-black-lightest"
147
+ className={styles.AvatarEllipsisIcon}
148
+ >
149
+ <Icon
150
+ source={MoreIcon}
151
+ size={getProperIconSize(size)}
152
+ color="bgtxt-absolute-white-dark"
153
+ />
154
+ </SmoothCornersBox>
155
+ {AvatarElement}
156
+ </div>
157
+ )
158
+ }
159
+
160
+ if (ellipsisType === 'count') {
161
+ return (
162
+ <React.Fragment key="ellipsis">
163
+ {AvatarElement}
164
+ <div
165
+ style={
166
+ {
167
+ '--b-avatar-group-ellipsis-ml': px(
168
+ Math.max(spacing, AVATAR_GROUP_DEFAULT_SPACING)
169
+ ),
170
+ } as React.CSSProperties
171
+ }
172
+ className={classNames(styles.AvatarEllipsisCountWrapper)}
173
+ >
174
+ <Text
175
+ typo={getProperTypoSize(size)}
176
+ color="txt-black-dark"
177
+ className={styles.AvatarEllipsisCount}
178
+ >
179
+ {getRestAvatarListCountText(avatarListCount, max)}
180
+ </Text>
181
+ </div>
182
+ </React.Fragment>
183
+ )
184
+ }
185
+
186
+ return null
187
+ })
188
+ }, [
189
+ avatarListCount,
190
+ max,
191
+ children,
192
+ renderAvatarElement,
193
+ ellipsisType,
194
+ AVATAR_BORDER_RADIUS,
195
+ size,
196
+ spacing,
197
+ ])
198
+
199
+ return (
200
+ <AvatarGroupContextProvider
201
+ value={useMemo(
202
+ () => ({
203
+ size,
204
+ }),
205
+ [size]
206
+ )}
207
+ >
208
+ <div
209
+ role="group"
210
+ ref={forwardedRef}
211
+ className={classNames(
212
+ styles.AvatarGroup,
213
+ styles[`size-${size}`],
214
+ className
215
+ )}
216
+ style={
217
+ {
218
+ '--b-avatar-group-spacing': px(spacing),
219
+ ...style,
220
+ } as React.CSSProperties
221
+ }
222
+ {...rest}
223
+ >
224
+ {AvatarListComponent}
225
+ </div>
226
+ </AvatarGroupContextProvider>
227
+ )
228
+ }
229
+ )