Skip to content

Commit 2e4edbe

Browse files
feat(avatar): build ShadCN avatar group
1 parent 625ae0e commit 2e4edbe

File tree

2 files changed

+97
-6
lines changed

2 files changed

+97
-6
lines changed

src/components/ui/__stories__/Avatar.stories.tsx

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { Meta, StoryObj } from "@storybook/react"
22

3-
import { Avatar } from "../avatar"
3+
import { Avatar, AvatarGroup } from "../avatar"
44
import { HStack, VStack } from "../flex"
55

66
const meta = {
@@ -27,6 +27,26 @@ export const Single: Story = {
2727
),
2828
}
2929

30+
export const Group: Story = {
31+
args: {
32+
name: "Dan Abrahmov",
33+
src: "https://bit.ly/dan-abramov",
34+
href: "#",
35+
},
36+
render: (args) => (
37+
<VStack className="gap-4">
38+
{(["sm", "xs"] as const).map((size) => (
39+
<AvatarGroup key={size} size={size} max={3}>
40+
<Avatar dataTest="one" {...args} />
41+
<Avatar dataTest="two" {...args} />
42+
<Avatar dataTest="three" {...args} />
43+
<Avatar {...args} />
44+
</AvatarGroup>
45+
))}
46+
</VStack>
47+
),
48+
}
49+
3050
export const WithUsername: Story = {
3151
args: {
3252
name: "Dan Abrahmov",

src/components/ui/avatar.tsx

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { LinkBox, LinkOverlay } from "./link-box"
1212
const avatarStyles = tv({
1313
slots: {
1414
container:
15-
"relative shrink-0 flex overflow-hidden rounded-full focus:outline-4 focus:-outline-offset-1 focus:rounded-full active:shadow-none [&_img]:hover:opacity-70 border border-transparent active:border-primary-hover",
15+
"relative shrink-0 flex overflow-hidden rounded-full focus:outline-4 focus:-outline-offset-1 focus:rounded-full active:shadow-none [&_img]:hover:opacity-70 border border-transparent active:border-primary-hover justify-center items-center",
1616
fallback: "bg-body text-body-inverse",
1717
},
1818
variants: {
@@ -115,13 +115,23 @@ export type AvatarProps = AvatarBaseProps &
115115
direction?: "column" | "row"
116116
name: string
117117
src: string
118+
dataTest?: string
118119
}
119120

120121
const Avatar = React.forwardRef<
121122
React.ElementRef<"span"> | React.ElementRef<"div">,
122123
AvatarProps
123124
>((props, ref) => {
124-
const { href, src, name, size, label, direction = "row" } = props
125+
const {
126+
href,
127+
src,
128+
name,
129+
size,
130+
label,
131+
className,
132+
direction = "row",
133+
dataTest,
134+
} = props
125135

126136
const commonLinkProps = {
127137
href,
@@ -165,8 +175,8 @@ const Avatar = React.forwardRef<
165175
}
166176

167177
return (
168-
<AvatarBase ref={ref} size={size} asChild>
169-
<BaseLink {...commonLinkProps}>
178+
<AvatarBase ref={ref} size={size} className={className} asChild>
179+
<BaseLink title={dataTest} {...commonLinkProps}>
170180
<AvatarImage src={src} />
171181
<AvatarFallback>{fallbackInitials}</AvatarFallback>
172182
</BaseLink>
@@ -175,4 +185,65 @@ const Avatar = React.forwardRef<
175185
})
176186
Avatar.displayName = "Avatar"
177187

178-
export { Avatar, AvatarBase, AvatarFallback, AvatarImage }
188+
type AvatarGroupProps = AvatarVariantProps &
189+
React.HTMLAttributes<HTMLDivElement> & {
190+
children: React.ReactNode
191+
max?: number
192+
}
193+
/**
194+
* Chakra v2 component as reference: https://github.com/chakra-ui/chakra-ui/blob/v2/packages/components/src/avatar/avatar-group.tsx
195+
*/
196+
const AvatarGroup = React.forwardRef<HTMLDivElement, AvatarGroupProps>(
197+
(props, ref) => {
198+
const { children, max, size, className, ...rest } = props
199+
200+
const validChildren = React.Children.toArray(children).filter((child) =>
201+
React.isValidElement(child)
202+
) as React.ReactElement[]
203+
204+
/**
205+
* The visible avatars from max
206+
*/
207+
const childrenWithinMax =
208+
max != null ? validChildren.slice(0, max) : validChildren
209+
/**
210+
* Number of hidden avatars from max
211+
*/
212+
const hiddenCount = max != null ? validChildren.length - max : 0
213+
214+
/**
215+
* Reversed children to handle implied z-index
216+
*/
217+
const reversedChildren = childrenWithinMax.reverse()
218+
219+
const clonedChildren = reversedChildren.map((child, idx) => {
220+
const isFirst = idx === 0
221+
return React.cloneElement(child, {
222+
className: cn(isFirst ? "me-0" : "-me-2"),
223+
size,
224+
})
225+
})
226+
227+
const { container, fallback } = avatarStyles({ size })
228+
229+
return (
230+
<div
231+
ref={ref}
232+
role="group"
233+
className={cn("flex flex-row-reverse", className)}
234+
{...rest}
235+
>
236+
{hiddenCount > 0 && (
237+
<span
238+
className={cn("-ms-2", container(), fallback())}
239+
>{`+${hiddenCount}`}</span>
240+
)}
241+
{clonedChildren}
242+
</div>
243+
)
244+
}
245+
)
246+
247+
AvatarGroup.displayName = "AvatarGroup"
248+
249+
export { Avatar, AvatarBase, AvatarFallback, AvatarGroup, AvatarImage }

0 commit comments

Comments
 (0)