Skip to content

Commit 6686d9a

Browse files
authored
Merge pull request #14749 from TylerAPfledderer/feat/avatar-shadcn-migration
[ShadCN]: Migrate Avatar to ShadCN
2 parents 8a6cea0 + b3ffefb commit 6686d9a

File tree

4 files changed

+276
-17
lines changed

4 files changed

+276
-17
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@hookform/resolvers": "^3.8.0",
3737
"@next/bundle-analyzer": "^14.2.5",
3838
"@radix-ui/react-accordion": "^1.2.0",
39+
"@radix-ui/react-avatar": "^1.1.2",
3940
"@radix-ui/react-checkbox": "^1.1.1",
4041
"@radix-ui/react-compose-refs": "^1.1.0",
4142
"@radix-ui/react-dialog": "^1.1.1",

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

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import * as React from "react"
2-
import { AvatarGroup, HStack, VStack } from "@chakra-ui/react"
3-
import { Meta, StoryObj } from "@storybook/react"
1+
import type { Meta, StoryObj } from "@storybook/react"
42

5-
import Avatar from "."
3+
import { Avatar, AvatarGroup } from "../avatar"
4+
import { HStack, VStack } from "../flex"
65

76
const meta = {
87
title: "Atoms / Media & Icons / Avatars",
@@ -15,13 +14,13 @@ type Story = StoryObj<typeof meta>
1514

1615
export const Single: Story = {
1716
args: {
18-
name: "Dan Abrahmov",
17+
name: "dan abrahmov",
1918
src: "https://bit.ly/dan-abramov",
2019
href: "#",
2120
},
2221
render: (args) => (
23-
<VStack spacing={4}>
24-
{["lg", "md", "sm", "xs"].map((size) => (
22+
<VStack className="gap-4">
23+
{(["lg", "md", "sm", "xs"] as const).map((size) => (
2524
<Avatar key={size} size={size} {...args} />
2625
))}
2726
</VStack>
@@ -35,12 +34,12 @@ export const Group: Story = {
3534
href: "#",
3635
},
3736
render: (args) => (
38-
<VStack spacing={4}>
39-
{["sm", "xs"].map((size) => (
37+
<VStack className="gap-4">
38+
{(["sm", "xs"] as const).map((size) => (
4039
<AvatarGroup key={size} size={size} max={3}>
41-
<Avatar {...args} />
42-
<Avatar {...args} />
43-
<Avatar {...args} />
40+
<Avatar dataTest="one" {...args} />
41+
<Avatar dataTest="two" {...args} />
42+
<Avatar dataTest="three" {...args} />
4443
<Avatar {...args} />
4544
</AvatarGroup>
4645
))}
@@ -51,19 +50,19 @@ export const Group: Story = {
5150
export const WithUsername: Story = {
5251
args: {
5352
name: "Dan Abrahmov",
54-
src: "https://bit.ly/dan-abramov",
55-
href: "#",
53+
src: "http://bit.ly/dan-abramov",
54+
href: "http://bit.ly/dan-abramov",
5655
label: "daneabrahmov",
5756
},
5857
render: (args) => (
59-
<HStack spacing={16}>
58+
<HStack className="gap-4">
6059
<VStack>
61-
{["md", "sm"].map((size, idx) => (
60+
{(["md", "sm", "xs"] as const).map((size, idx) => (
6261
<Avatar key={idx} size={size} {...args} />
6362
))}
6463
</VStack>
6564
<VStack>
66-
{["md", "sm"].map((size, idx) => (
65+
{(["md", "sm", "xs"] as const).map((size, idx) => (
6766
<Avatar key={idx} size={size} direction="column" {...args} />
6867
))}
6968
</VStack>

src/components/ui/avatar.tsx

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import * as React from "react"
2+
import upperCase from "lodash/upperCase"
3+
import { tv, type VariantProps } from "tailwind-variants"
4+
import * as AvatarPrimitive from "@radix-ui/react-avatar"
5+
6+
import { cn } from "@/lib/utils/cn"
7+
8+
import { Center } from "./flex"
9+
import { BaseLink, type LinkProps } from "./Link"
10+
import { LinkBox, LinkOverlay } from "./link-box"
11+
12+
const avatarStyles = tv({
13+
slots: {
14+
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 justify-center items-center",
16+
fallback: "bg-body text-body-inverse",
17+
},
18+
variants: {
19+
size: {
20+
xs: {
21+
container:
22+
"size-6 hover:shadow-[2px_2px_0_var(--avatar-base-shadow-color)] peer-hover:shadow-[2px_2px_0_var(--avatar-base-shadow-color)]",
23+
fallback: "text-2xs",
24+
},
25+
sm: {
26+
container:
27+
"size-8 hover:shadow-[2px_2px_0_var(--avatar-base-shadow-color)] peer-hover:shadow-[2px_2px_0_var(--avatar-base-shadow-color)]",
28+
fallback: "text-sm",
29+
},
30+
md: {
31+
container:
32+
"size-12 hover:shadow-[4px_4px_0_var(--avatar-base-shadow-color)] peer-hover:shadow-[4px_4px_0_var(--avatar-base-shadow-color)]",
33+
fallback: "text-lg",
34+
},
35+
lg: {
36+
container:
37+
"size-16 hover:shadow-[4px_4px_0_var(--avatar-base-shadow-color)] peer-hover:shadow-[4px_4px_0_var(--avatar-base-shadow-color)]",
38+
fallback: "text-2xl",
39+
},
40+
},
41+
},
42+
defaultVariants: {
43+
size: "md",
44+
},
45+
})
46+
47+
type AvatarVariantProps = VariantProps<typeof avatarStyles>
48+
49+
const AvatarStylesContext =
50+
React.createContext<ReturnType<typeof avatarStyles>>(avatarStyles())
51+
52+
const useAvatarStyles = () => React.useContext(AvatarStylesContext)
53+
54+
type AvatarBaseProps = React.ComponentProps<typeof AvatarPrimitive.Root> &
55+
AvatarVariantProps
56+
57+
const AvatarBase = React.forwardRef<
58+
React.ElementRef<typeof AvatarPrimitive.Root>,
59+
AvatarBaseProps
60+
>(({ className, size, ...props }, ref) => (
61+
<AvatarStylesContext.Provider value={avatarStyles({ size })}>
62+
<AvatarPrimitive.Root
63+
ref={ref}
64+
style={
65+
{
66+
"--avatar-base-shadow-color": "hsl(var(--primary-low-contrast))",
67+
} as React.CSSProperties
68+
}
69+
className={avatarStyles({ size }).container({ className })}
70+
{...props}
71+
/>
72+
</AvatarStylesContext.Provider>
73+
))
74+
AvatarBase.displayName = AvatarPrimitive.Root.displayName
75+
76+
const AvatarImage = React.forwardRef<
77+
React.ElementRef<typeof AvatarPrimitive.Image>,
78+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
79+
>(({ className, alt = "", ...props }, ref) => (
80+
<AvatarPrimitive.Image
81+
ref={ref}
82+
className={cn("aspect-square h-full w-full", className)}
83+
alt={alt}
84+
{...props}
85+
/>
86+
))
87+
AvatarImage.displayName = AvatarPrimitive.Image.displayName
88+
89+
const AvatarFallback = React.forwardRef<
90+
React.ElementRef<typeof AvatarPrimitive.Fallback>,
91+
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> &
92+
VariantProps<typeof avatarStyles>
93+
>(({ className, ...props }, ref) => {
94+
const { fallback } = useAvatarStyles()
95+
return (
96+
<AvatarPrimitive.Fallback
97+
ref={ref}
98+
className={cn(
99+
"flex h-full w-full items-center justify-center rounded-full",
100+
fallback(),
101+
className
102+
)}
103+
{...props}
104+
/>
105+
)
106+
})
107+
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
108+
109+
export type AvatarProps = AvatarBaseProps &
110+
Required<Pick<LinkProps, "href">> & {
111+
label?: string
112+
/**
113+
* @default "row"
114+
*/
115+
direction?: "column" | "row"
116+
name: string
117+
src: string
118+
dataTest?: string
119+
}
120+
121+
const Avatar = React.forwardRef<
122+
React.ElementRef<"span"> | React.ElementRef<"div">,
123+
AvatarProps
124+
>((props, ref) => {
125+
const {
126+
href,
127+
src,
128+
name,
129+
size,
130+
label,
131+
className,
132+
direction = "row",
133+
dataTest,
134+
} = props
135+
136+
const commonLinkProps = {
137+
href,
138+
className: "not-[:hover]:no-underline",
139+
}
140+
141+
const fallbackInitials = upperCase(
142+
name
143+
.split(" ")
144+
.map((n) => n[0])
145+
.join("")
146+
)
147+
148+
if (label) {
149+
const _direction: "flex-col-reverse" | "flex-row-reverse" =
150+
direction === "row" ? "flex-row-reverse" : "flex-col-reverse"
151+
152+
const _ref = ref as React.ForwardedRef<HTMLDivElement>
153+
return (
154+
<LinkBox
155+
// !! Inconsistent strategy, using `as` prop instead of `asChild` bool
156+
as={Center}
157+
ref={_ref}
158+
className={cn(_direction, "gap-x-1 gap-y-0")}
159+
>
160+
<LinkOverlay
161+
asChild
162+
className={cn(
163+
"peer z-overlay inline-flex items-center gap-1 p-1",
164+
size !== "md" ? "text-xs" : "text-sm"
165+
)}
166+
>
167+
<BaseLink {...commonLinkProps}>{label}</BaseLink>
168+
</LinkOverlay>
169+
<AvatarBase size={size}>
170+
<AvatarImage src={src} />
171+
<AvatarFallback>{fallbackInitials}</AvatarFallback>
172+
</AvatarBase>
173+
</LinkBox>
174+
)
175+
}
176+
177+
return (
178+
<AvatarBase ref={ref} size={size} className={className} asChild>
179+
<BaseLink title={dataTest} {...commonLinkProps}>
180+
<AvatarImage src={src} />
181+
<AvatarFallback>{fallbackInitials}</AvatarFallback>
182+
</BaseLink>
183+
</AvatarBase>
184+
)
185+
})
186+
Avatar.displayName = "Avatar"
187+
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 }

yarn.lock

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3811,6 +3811,16 @@
38113811
dependencies:
38123812
"@radix-ui/react-primitive" "2.0.0"
38133813

3814+
"@radix-ui/react-avatar@^1.1.2":
3815+
version "1.1.2"
3816+
resolved "https://registry.yarnpkg.com/@radix-ui/react-avatar/-/react-avatar-1.1.2.tgz#24af4c66bb5271460a4a6b74c4f4f9d4789d3d90"
3817+
integrity sha512-GaC7bXQZ5VgZvVvsJ5mu/AEbjYLnhhkoidOboC50Z6FFlLA03wG2ianUoH+zgDQ31/9gCF59bE4+2bBgTyMiig==
3818+
dependencies:
3819+
"@radix-ui/react-context" "1.1.1"
3820+
"@radix-ui/react-primitive" "2.0.1"
3821+
"@radix-ui/react-use-callback-ref" "1.1.0"
3822+
"@radix-ui/react-use-layout-effect" "1.1.0"
3823+
38143824
"@radix-ui/react-checkbox@^1.1.1":
38153825
version "1.1.1"
38163826
resolved "https://registry.yarnpkg.com/@radix-ui/react-checkbox/-/react-checkbox-1.1.1.tgz#a559c4303957d797acee99914480b755aa1f27d6"

0 commit comments

Comments
 (0)