Skip to content

Commit 49d576e

Browse files
chore: adjustmens to Avatar, GroupAvatar and ChannelAvatar (#3087)
### Removed `overflowCount` prop from `GroupAvatar` The `overflowCount` prop has been removed from `GroupAvatarProps`. Overflow is now calculated automatically: when `displayMembers` has more than 4 entries, only the first 2 are rendered and a "+N" badge shows the remainder. ### Removed `overflowCount` prop from `AvatarStack` The `overflowCount` prop has been removed from `AvatarStack`. Overflow is now calculated automatically based on the new `capLimit` prop (defaults to 3): when `displayInfo` has more entries than `capLimit`, only the first `capLimit` items are rendered and a "+N" badge shows the remainder. ### Removed `overflowCount` and explicit `displayMembers` from `ChannelAvatarProps` The `overflowCount` prop has been removed from `ChannelAvatarProps`. The `displayMembers` prop is no longer declared directly on `ChannelAvatarProps` — it is still accepted because it is inherited from `Partial<GroupAvatarProps>`, but it is no longer independently typed on the interface. ### `ChannelAvatarProps.size` type widened The `size` prop on `ChannelAvatarProps` changed from `GroupAvatarProps['size']` (i.e., `'2xl' | 'xl' | 'lg' | null`) to `GroupAvatarProps['size'] | AvatarProps['size']` (i.e., `'2xl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs' | (string & {}) | null`). This is not a breaking change for existing code but changes the accepted values. ### `getGroupChannelDisplayInfo` return value changed The utility function `getGroupChannelDisplayInfo` (from `src/components/ChannelListItem/utils.tsx`) no longer returns `overflowCount` in its result object. ### `AvatarProps.size` type widened to accept arbitrary strings The `size` prop on `AvatarProps` changed from the union `'2xl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs' | null` to `'2xl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs' | (string & {}) | null`. This allows passing custom size strings while preserving autocomplete for the known values. ### `GroupAvatarProps.size` type widened to accept arbitrary strings The `size` prop on `GroupAvatarProps` changed from `'2xl' | 'xl' | 'lg' | null` to `'2xl' | 'xl' | 'lg' | (string & {}) | null`. This allows passing custom size strings while preserving autocomplete for the known values. ### New `capLimit` prop on `AvatarStack` A new optional `capLimit` prop (type `number`, default `3`) controls the maximum number of avatars rendered before overflow. When `displayInfo` has more entries than `capLimit`, only the first `capLimit` items are shown and a "+N" badge displays the remainder. ### `GroupAvatarMember` type gains optional `id` field The `GroupAvatarMember` type now includes an optional `id?: string` field. When present, it is used as the React `key` for rendered avatars instead of the fallback `${userName}-${imageUrl}-${index}` pattern. ## Behavioral Changes ### `ChannelAvatar` always renders via `GroupAvatar` internally Previously, `ChannelAvatar` conditionally chose between `<Avatar>` (for 0–1 members) and `<GroupAvatar>` (for 2+ members). It now always delegates to `<GroupAvatar>`, which itself renders a single `<Avatar>` when fewer than 2 members are present. The visual output is unchanged, but the component tree structure differs. ### `GroupAvatar` auto-caps displayed members at 4 (or 2 with overflow) Previously, callers controlled how many members to display and the overflow count. Now `GroupAvatar` internally slices `displayMembers`: - **4 or fewer members:** all are rendered, no badge. - **More than 4 members:** only the first 2 are rendered, with a "+N" badge showing the count of remaining members. ### `AvatarStack` auto-caps displayed items (default: 3, configurable via `capLimit`) Previously, callers controlled how many items to display and the overflow count. Now `AvatarStack` internally slices `displayInfo` based on the `capLimit` prop (default `3`): - **`capLimit` or fewer items:** all are rendered, no badge. - **More than `capLimit` items:** only the first `capLimit` are rendered, with a "+N" badge showing the count of remaining items. ### `TypingIndicator` no longer manually slices typing users The `TypingIndicator` component previously sliced the list of typing users to a maximum of 3 before passing to `AvatarStack`. It now passes all typing users, relying on `AvatarStack`'s internal capping (also at 3). The net visual result is unchanged.
1 parent 7b5835e commit 49d576e

12 files changed

Lines changed: 410 additions & 69 deletions

File tree

examples/vite/src/ChatLayout/Panels.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,6 @@ export const ChannelsPanels = ({
102102
<ChatView.Selector iconOnly={iconOnly} itemSet={itemSet} />
103103
<WithComponents
104104
overrides={{
105-
// @ts-expect-error TODO: adjust the sizing
106105
Avatar: ChannelAvatar,
107106
}}
108107
>

src/components/Avatar/Avatar.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ export type AvatarProps = {
1414
userName?: string;
1515
/** Online status indicator, not rendered if not of type boolean */
1616
isOnline?: boolean;
17-
18-
size: '2xl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs' | null;
17+
size: '2xl' | 'xl' | 'lg' | 'md' | 'sm' | 'xs' | (string & {}) | null;
1918
} & ComponentPropsWithoutRef<'div'>;
2019

2120
const getInitials = (name?: string) => {

src/components/Avatar/AvatarStack.tsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,30 @@
1-
import React, { type ComponentProps, type ElementType } from 'react';
1+
import React, { type ComponentProps, type ElementType, useMemo } from 'react';
22
import { useComponentContext } from '../../context';
33
import { type AvatarProps, Avatar as DefaultAvatar } from './Avatar';
44
import clsx from 'clsx';
55
import { Badge, type BadgeSize } from '../Badge';
66

77
export function AvatarStack({
88
badgeSize,
9+
capLimit = 3,
910
component: Component = 'div',
1011
displayInfo = [],
11-
overflowCount,
1212
size,
1313
}: {
1414
component?: ElementType;
1515
displayInfo?: (Pick<AvatarProps, 'imageUrl' | 'userName'> & { id?: string })[];
16-
overflowCount?: number;
1716
size: 'md' | 'sm' | 'xs' | null;
1817
badgeSize?: BadgeSize;
18+
capLimit?: number;
1919
}) {
2020
const { Avatar = DefaultAvatar } = useComponentContext(AvatarStack.name);
2121

22+
const displayInfoToRender = useMemo(
23+
() => (displayInfo.length > capLimit ? displayInfo.slice(0, capLimit) : displayInfo),
24+
[displayInfo, capLimit],
25+
);
26+
const overflowCount = displayInfo.length - displayInfoToRender.length;
27+
2228
if (!displayInfo.length) {
2329
return null;
2430
}
@@ -28,8 +34,9 @@ export function AvatarStack({
2834
className={clsx('str-chat__avatar-stack', {
2935
[`str-chat__avatar-stack--size-${size}`]: typeof size === 'string',
3036
})}
37+
data-testid='avatar-stack'
3138
>
32-
{displayInfo.map((info, index) => (
39+
{displayInfoToRender.map((info, index) => (
3340
<Avatar
3441
imageUrl={info.imageUrl}
3542
key={info.id ?? `${info.userName}-${info.imageUrl}-${index}`}
@@ -40,6 +47,7 @@ export function AvatarStack({
4047
{typeof overflowCount === 'number' && overflowCount > 0 && (
4148
<Badge
4249
className='str-chat__avatar-stack__count-badge'
50+
data-testid='avatar-stack-count-badge'
4351
size={badgeSize ?? size}
4452
variant='counter'
4553
>
Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,31 @@
1-
import React from 'react';
1+
import React, { useMemo } from 'react';
22

3-
import { Avatar, GroupAvatar } from './';
3+
import { GroupAvatar } from './';
44
import type { AvatarProps, GroupAvatarProps } from './';
5-
import type { GroupAvatarMember } from './GroupAvatar';
65

76
export type ChannelAvatarProps = Partial<Omit<GroupAvatarProps & AvatarProps, 'size'>> & {
8-
size: GroupAvatarProps['size'];
9-
/** When set with length >= 2, GroupAvatar is used. */
10-
displayMembers?: GroupAvatarMember[];
11-
overflowCount?: number;
7+
size: GroupAvatarProps['size'] | AvatarProps['size'];
128
};
139

1410
export const ChannelAvatar = ({
1511
displayMembers,
1612
imageUrl,
17-
overflowCount,
1813
size,
1914
userName,
2015
...sharedProps
2116
}: ChannelAvatarProps) => {
22-
if ((displayMembers?.length ?? 0) >= 2) {
23-
return (
24-
<GroupAvatar
25-
displayMembers={displayMembers}
26-
overflowCount={overflowCount}
27-
size={size}
28-
{...sharedProps}
29-
/>
30-
);
31-
}
32-
return <Avatar imageUrl={imageUrl} size={size} userName={userName} {...sharedProps} />;
17+
const displayInfo = useMemo(() => {
18+
if (displayMembers && displayMembers.length > 0) {
19+
return displayMembers;
20+
}
21+
22+
return [
23+
{
24+
imageUrl,
25+
userName,
26+
},
27+
];
28+
}, [displayMembers, imageUrl, userName]);
29+
30+
return <GroupAvatar displayMembers={displayInfo} size={size} {...sharedProps} />;
3331
};

src/components/Avatar/GroupAvatar.tsx

Lines changed: 21 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,39 @@
11
import clsx from 'clsx';
2-
import React, { type ComponentPropsWithoutRef } from 'react';
2+
import React, { type ComponentPropsWithoutRef, useMemo } from 'react';
33
import { Avatar, type AvatarProps } from './Avatar';
44
import { Badge, type BadgeSize } from '../Badge';
55

66
export type GroupAvatarMember = {
77
imageUrl?: string;
88
userName?: string;
9+
id?: string;
910
};
1011

11-
export type GroupAvatarProps = ComponentPropsWithoutRef<'div'> & {
12-
/** List of members to show as avatars; at most 2 when overflowCount is set, otherwise 4. Defaults to [] when omitted. */
12+
export type GroupAvatarProps = {
13+
/** List of members to show as avatars; at most 2 when there's more than 4 members, otherwise 4. Defaults to [] when omitted. */
1314
displayMembers?: GroupAvatarMember[];
14-
/** Optional count for the "+N" badge when there are more members than shown. */
15-
overflowCount?: number;
16-
size: '2xl' | 'xl' | 'lg' | null;
15+
size: '2xl' | 'xl' | 'lg' | (string & {}) | null;
1716
badgeSize?: BadgeSize;
1817
isOnline?: boolean;
19-
};
18+
} & ComponentPropsWithoutRef<'div'>;
2019

2120
/**
2221
* Avatar component to display multiple users' avatars in a group.
2322
* Renders a single Avatar if fewer than 2 members. Otherwise, renders up to 2 avatars (when overflowCount is set) or 4, plus an optional +N badge.
2423
*/
25-
// TODO: rename to AvatarGroup
2624
export const GroupAvatar = ({
2725
badgeSize,
2826
className,
2927
displayMembers = [],
3028
isOnline,
31-
overflowCount,
3229
size,
3330
...rest
3431
}: GroupAvatarProps) => {
35-
const displayCountBadge = typeof overflowCount === 'number' && overflowCount > 0;
32+
const displayMembersToRender = useMemo(
33+
() => (displayMembers.length > 4 ? displayMembers.slice(0, 2) : displayMembers),
34+
[displayMembers],
35+
);
36+
const overflowCount = displayMembers.length - displayMembersToRender.length;
3637

3738
if (displayMembers.length < 2) {
3839
const firstUser = displayMembers[0];
@@ -72,19 +73,18 @@ export const GroupAvatar = ({
7273
role='button'
7374
{...rest}
7475
>
75-
{displayMembers
76-
.slice(0, displayCountBadge ? 2 : 4)
77-
.map(({ imageUrl, userName }, index) => (
78-
<Avatar
79-
imageUrl={imageUrl}
80-
key={`${userName}-${imageUrl}-${index}`}
81-
size={avatarSize}
82-
userName={userName}
83-
/>
84-
))}
85-
{displayCountBadge && (
76+
{displayMembersToRender.map(({ id, imageUrl, userName }, index) => (
77+
<Avatar
78+
imageUrl={imageUrl}
79+
key={id || `${userName}-${imageUrl}-${index}`}
80+
size={avatarSize}
81+
userName={userName}
82+
/>
83+
))}
84+
{typeof overflowCount === 'number' && overflowCount > 0 && (
8685
<Badge
8786
className='str-chat__avatar-group__count-badge'
87+
data-testid='group-avatar-count-badge'
8888
size={badgeSize}
8989
variant='counter'
9090
>
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import React from 'react';
2+
3+
import { cleanup, render } from '@testing-library/react';
4+
5+
import { AvatarStack } from '../AvatarStack';
6+
import { WithComponents } from '../../../context';
7+
8+
afterEach(cleanup);
9+
10+
const member1 = { id: '1', imageUrl: 'img1.png', userName: 'Alice' };
11+
const member2 = { id: '2', imageUrl: 'img2.png', userName: 'Bob' };
12+
const member3 = { id: '3', imageUrl: 'img3.png', userName: 'Charlie' };
13+
const member4 = { id: '4', imageUrl: 'img4.png', userName: 'Diana' };
14+
15+
describe('AvatarStack', () => {
16+
it('should render nothing when displayInfo is empty or not provided', () => {
17+
const r1 = render(<AvatarStack displayInfo={[]} size='md' />);
18+
expect(r1.queryByTestId('avatar-stack')).not.toBeInTheDocument();
19+
20+
const r2 = render(<AvatarStack size='md' />);
21+
expect(r2.queryByTestId('avatar-stack')).not.toBeInTheDocument();
22+
});
23+
24+
it('should render avatars for each item in displayInfo', () => {
25+
const { getAllByTestId } = render(
26+
<AvatarStack displayInfo={[member1, member2]} size='md' />,
27+
);
28+
expect(getAllByTestId('avatar')).toHaveLength(2);
29+
});
30+
31+
it('should render the correct avatar images', () => {
32+
const { getAllByTestId } = render(
33+
<AvatarStack displayInfo={[member1, member2]} size='md' />,
34+
);
35+
const images = getAllByTestId('avatar-img');
36+
expect(images[0]).toHaveAttribute('src', 'img1.png');
37+
expect(images[1]).toHaveAttribute('src', 'img2.png');
38+
});
39+
40+
it('should apply the size class to the root element', () => {
41+
const { getByTestId } = render(<AvatarStack displayInfo={[member1]} size='sm' />);
42+
expect(getByTestId('avatar-stack')).toHaveClass('str-chat__avatar-stack--size-sm');
43+
});
44+
45+
it('should not apply a size class when size is null', () => {
46+
const { getByTestId } = render(<AvatarStack displayInfo={[member1]} size={null} />);
47+
const root = getByTestId('avatar-stack');
48+
expect(root).toBeInTheDocument();
49+
expect(root.className).not.toContain('str-chat__avatar-stack--size-');
50+
});
51+
52+
it('should render at most 3 avatars', () => {
53+
const { getAllByTestId } = render(
54+
<AvatarStack displayInfo={[member1, member2, member3, member4]} size='md' />,
55+
);
56+
expect(getAllByTestId('avatar')).toHaveLength(3);
57+
});
58+
59+
it('should render overflow count badge when more than 3 items', () => {
60+
const { getByTestId } = render(
61+
<AvatarStack displayInfo={[member1, member2, member3, member4]} size='md' />,
62+
);
63+
const badge = getByTestId('avatar-stack-count-badge');
64+
expect(badge).toBeInTheDocument();
65+
expect(badge).toHaveTextContent('+1');
66+
});
67+
68+
it('should not render overflow count badge when 3 or fewer items', () => {
69+
const { queryByTestId } = render(
70+
<AvatarStack displayInfo={[member1, member2, member3]} size='md' />,
71+
);
72+
expect(queryByTestId('avatar-stack-count-badge')).not.toBeInTheDocument();
73+
});
74+
75+
it('should render with a custom component wrapper', () => {
76+
const { getByTestId } = render(
77+
<AvatarStack component='section' displayInfo={[member1]} size='md' />,
78+
);
79+
expect(getByTestId('avatar-stack').tagName).toBe('SECTION');
80+
});
81+
82+
it('should default to div wrapper', () => {
83+
const { getByTestId } = render(<AvatarStack displayInfo={[member1]} size='md' />);
84+
expect(getByTestId('avatar-stack').tagName).toBe('DIV');
85+
});
86+
87+
it('should use custom Avatar from ComponentContext', () => {
88+
const CustomAvatar = ({ userName }: { userName?: string }) => (
89+
<div data-testid='custom-avatar'>{userName}</div>
90+
);
91+
92+
const { getAllByTestId, queryAllByTestId } = render(
93+
<WithComponents overrides={{ Avatar: CustomAvatar }}>
94+
<AvatarStack displayInfo={[member1, member2]} size='md' />
95+
</WithComponents>,
96+
);
97+
98+
expect(getAllByTestId('custom-avatar')).toHaveLength(2);
99+
expect(getAllByTestId('custom-avatar')[0]).toHaveTextContent('Alice');
100+
expect(getAllByTestId('custom-avatar')[1]).toHaveTextContent('Bob');
101+
expect(queryAllByTestId('avatar')).toHaveLength(0);
102+
});
103+
});

0 commit comments

Comments
 (0)