11import { clsx } from 'clsx'
22import React , { useEffect , useRef , useState } from 'react'
3- import type { SxProp } from '../sx'
3+ import styled from 'styled-components'
4+ import { get } from '../constants'
5+ import Box from '../Box'
6+ import type { BetterCssProperties , BetterSystemStyleObject , SxProp } from '../sx'
7+ import sx , { merge } from '../sx'
48import type { AvatarProps } from '../Avatar/Avatar'
59import { DEFAULT_AVATAR_SIZE } from '../Avatar/Avatar'
610import type { ResponsiveValue } from '../hooks/useResponsiveValue'
711import { isResponsiveValue } from '../hooks/useResponsiveValue'
12+ import { getBreakpointDeclarations } from '../utils/getBreakpointDeclarations'
813import { defaultSxProp } from '../utils/defaultSxProp'
914import type { WidthOnlyViewportRangeKeys } from '../utils/types/ViewportRangeKeys'
1015import classes from './AvatarStack.module.css'
16+ import { toggleStyledComponent } from '../internal/utils/toggleStyledComponent'
17+ import { useFeatureFlag } from '../FeatureFlags'
1118import { hasInteractiveNodes } from '../internal/utils/hasInteractiveNodes'
12- import { toggleSxComponent } from '../internal/utils/toggleSxComponent '
19+ import getGlobalFocusStyles from '../internal/utils/getGlobalFocusStyles '
1320
14- const transformChildren = ( children : React . ReactNode ) => {
21+ type StyledAvatarStackWrapperProps = {
22+ count ?: number
23+ } & SxProp
24+
25+ const CSS_MODULES_FEATURE_FLAG = 'primer_react_css_modules_ga'
26+
27+ const AvatarStackWrapper = toggleStyledComponent (
28+ CSS_MODULES_FEATURE_FLAG ,
29+ 'span' ,
30+ styled . span < StyledAvatarStackWrapperProps > `
31+ --avatar-border-width: 1px;
32+ --overlap-size: calc(var(--avatar-stack-size) * 0.55);
33+ --overlap-size-avatar-three-plus: calc(var(--avatar-stack-size) * 0.85);
34+ --mask-size: calc(100% + (var(--avatar-border-width) * 2));
35+ --mask-start: -1;
36+ --opacity-step: 15%;
37+
38+ display: flex;
39+ position: relative;
40+ height: var(--avatar-stack-size);
41+ min-width: var(--avatar-stack-size);
42+ isolation: isolate;
43+
44+ .pc-AvatarStackBody {
45+ display: flex;
46+ position: absolute;
47+
48+ ${ getGlobalFocusStyles ( '1px' ) }
49+ }
50+
51+ .pc-AvatarItem {
52+ --avatar-size: var(--avatar-stack-size);
53+ flex-shrink: 0;
54+ height: var(--avatar-stack-size);
55+ width: var(--avatar-stack-size);
56+ position: relative;
57+ overflow: hidden;
58+ display: flex;
59+ transition:
60+ margin 0.2s ease-in-out,
61+ opacity 0.2s ease-in-out,
62+ mask-position 0.2s ease-in-out,
63+ mask-size 0.2s ease-in-out;
64+
65+ &:is(img) {
66+ box-shadow: 0 0 0 var(--avatar-border-width)
67+ ${ props => ( props . count === 1 ? get ( 'colors.avatar.border' ) : 'transparent' ) } ;
68+ }
69+
70+ &:first-child {
71+ margin-inline-start: 0;
72+ }
73+
74+ &:nth-child(n + 2) {
75+ margin-inline-start: calc(var(--overlap-size) * -1);
76+ mask-image: radial-gradient(at 50% 50%, rgb(0, 0, 0) 70%, rgba(0, 0, 0, 0) 71%),
77+ linear-gradient(rgb(0, 0, 0) 0 0);
78+ mask-repeat: no-repeat, no-repeat;
79+ mask-size:
80+ var(--mask-size) var(--mask-size),
81+ auto;
82+ mask-composite: exclude;
83+ // HORIZONTAL POSITION CALC FORMULA EXPLAINED:
84+ // width of the visible part of the avatar ➡️ var(--avatar-stack-size) - var(--overlap-size)
85+ // multiply by -1 for left-aligned, 1 for right-aligned ➡️ var(--mask-start)
86+ // subtract the avatar border width ➡️ var(--avatar-border-width)
87+ mask-position:
88+ calc((var(--avatar-stack-size) - var(--overlap-size)) * var(--mask-start) - var(--avatar-border-width)) center,
89+ 0 0;
90+ // HACK: This padding fixes a weird rendering bug where a tiiiiny outline is visible at the edges of the element
91+ padding: 0.1px;
92+ }
93+
94+ &:nth-child(n + 3) {
95+ --overlap-size: var(--overlap-size-avatar-three-plus);
96+ opacity: calc(100% - 2 * var(--opacity-step));
97+ }
98+
99+ &:nth-child(n + 4) {
100+ opacity: calc(100% - 3 * var(--opacity-step));
101+ }
102+
103+ &:nth-child(n + 5) {
104+ opacity: calc(100% - 4 * var(--opacity-step));
105+ }
106+
107+ &:nth-child(n + 6) {
108+ opacity: 0;
109+ visibility: hidden;
110+ }
111+ }
112+
113+ &.pc-AvatarStack--two {
114+ // MIN-WIDTH CALC FORMULA EXPLAINED:
115+ // avatar size ➡️ var(--avatar-stack-size)
116+ // plus the visible part of the 2nd avatar ➡️ var(--avatar-stack-size) - var(--overlap-size)
117+ min-width: calc(var(--avatar-stack-size) + (var(--avatar-stack-size) - var(--overlap-size)));
118+ }
119+
120+ &.pc-AvatarStack--three {
121+ // MIN-WIDTH CALC FORMULA EXPLAINED:
122+ // avatar size ➡️ var(--avatar-stack-size)
123+ // plus the visible part of the 2nd avatar ➡️ var(--avatar-stack-size) - var(--overlap-size)
124+ // plus the visible part of the 3rd avatar ➡️ var(--avatar-stack-size) - var(--overlap-size-avatar-three-plus)
125+ min-width: calc(
126+ var(--avatar-stack-size) + (var(--avatar-stack-size) - var(--overlap-size)) +
127+ (var(--avatar-stack-size) - var(--overlap-size-avatar-three-plus))
128+ );
129+ }
130+
131+ &.pc-AvatarStack--three-plus {
132+ // MIN-WIDTH CALC FORMULA EXPLAINED:
133+ // avatar size ➡️ var(--avatar-stack-size)
134+ // plus the visible part of the 2nd avatar ➡️ var(--avatar-stack-size) - var(--overlap-size)
135+ // plus the visible part of the 3rd AND 4th avatar ➡️ (var(--avatar-stack-size) - var(--overlap-size-avatar-three-plus)) * 2
136+ min-width: calc(
137+ var(--avatar-stack-size) + (var(--avatar-stack-size) - var(--overlap-size)) +
138+ (var(--avatar-stack-size) - var(--overlap-size-avatar-three-plus)) * 2
139+ );
140+ }
141+
142+ &.pc-AvatarStack--right {
143+ --mask-start: 1;
144+ direction: rtl;
145+ }
146+
147+ .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):hover,
148+ .pc-AvatarStackBody:not(.pc-AvatarStack--disableExpand):focus-within {
149+ width: auto;
150+
151+ .pc-AvatarItem {
152+ // reset size of the mask to prevent unintentially clipping due to the additional size created by the border width
153+ --mask-size: 100%;
154+ margin-inline-start: ${ get ( 'space.1' ) } ;
155+ opacity: 1;
156+ visibility: visible;
157+ // HORIZONTAL POSITION CALC FORMULA EXPLAINED:
158+ // width of the full avatar ➡️ var(--avatar-stack-size)
159+ // multiply by -1 for left-aligned, 1 for right-aligned ➡️ var(--mask-start)
160+ mask-position:
161+ calc(var(--avatar-stack-size) * var(--mask-start)) center,
162+ 0 0;
163+
164+ ${ getGlobalFocusStyles ( '1px' ) }
165+
166+ &:first-child {
167+ margin-inline-start: 0;
168+ }
169+ }
170+ }
171+
172+ .pc-AvatarStack--disableExpand {
173+ position: relative;
174+ }
175+
176+ ${ sx } ;
177+ ` ,
178+ )
179+
180+ const transformChildren = ( children : React . ReactNode , enabled : boolean ) => {
15181 return React . Children . map ( children , child => {
16182 if ( ! React . isValidElement ( child ) ) return child
17183 return React . cloneElement ( child , {
18184 ...child . props ,
19- className : clsx ( child . props . className , 'pc-AvatarItem' , classes . AvatarItem ) ,
185+ className : clsx ( child . props . className , 'pc-AvatarItem' , { [ classes . AvatarItem ] : enabled } ) ,
20186 } )
21187 } )
22188}
@@ -43,16 +209,28 @@ const AvatarStackBody = ({
43209 const bodyClassNames = clsx ( 'pc-AvatarStackBody' , {
44210 'pc-AvatarStack--disableExpand' : disableExpand ,
45211 } )
212+ const enabled = useFeatureFlag ( CSS_MODULES_FEATURE_FLAG )
46213
214+ if ( enabled ) {
215+ return (
216+ < div
217+ data-disable-expand = { disableExpand ? '' : undefined }
218+ className = { clsx ( bodyClassNames , classes . AvatarStackBody ) }
219+ tabIndex = { ! hasInteractiveChildren && ! disableExpand ? 0 : undefined }
220+ ref = { stackContainer }
221+ >
222+ { children }
223+ </ div >
224+ )
225+ }
47226 return (
48- < div
49- data-disable-expand = { disableExpand ? '' : undefined }
50- className = { clsx ( bodyClassNames , classes . AvatarStackBody ) }
227+ < Box
228+ className = { bodyClassNames }
51229 tabIndex = { ! hasInteractiveChildren && ! disableExpand ? 0 : undefined }
52230 ref = { stackContainer }
53231 >
54232 { children }
55- </ div >
233+ </ Box >
56234 )
57235}
58236
@@ -65,6 +243,7 @@ const AvatarStack = ({
65243 style,
66244 sx : sxProp = defaultSxProp ,
67245} : AvatarStackProps ) => {
246+ const enabled = useFeatureFlag ( CSS_MODULES_FEATURE_FLAG )
68247 const [ hasInteractiveChildren , setHasInteractiveChildren ] = useState < boolean | undefined > ( false )
69248 const stackContainer = useRef < HTMLDivElement > ( null )
70249
@@ -142,46 +321,66 @@ const AvatarStack = ({
142321 const getResponsiveAvatarSizeStyles = ( ) => {
143322 // if there is no size set on the AvatarStack, use the `size` props of the Avatar children to set the `--avatar-stack-size` CSS variable
144323 if ( ! size ) {
145- return {
146- '--stackSize-narrow' : `${ childSizes . narrow } px` ,
147- '--stackSize-regular' : `${ childSizes . regular } px` ,
148- '--stackSize-wide' : `${ childSizes . wide } px` ,
324+ if ( enabled ) {
325+ return {
326+ '--stackSize-narrow' : `${ childSizes . narrow } px` ,
327+ '--stackSize-regular' : `${ childSizes . regular } px` ,
328+ '--stackSize-wide' : `${ childSizes . wide } px` ,
329+ }
149330 }
331+
332+ return getBreakpointDeclarations (
333+ childSizes ,
334+ '--avatar-stack-size' as keyof React . CSSProperties ,
335+ value => `${ value } px` ,
336+ )
150337 }
151338
152339 // if the `size` prop is set and responsive, set the `--avatar-stack-size` CSS variable for each viewport
153340 if ( isResponsiveValue ( size ) ) {
154- return {
155- '--stackSize-narrow' : `${ size . narrow || DEFAULT_AVATAR_SIZE } px` ,
156- '--stackSize-regular' : `${ size . regular || DEFAULT_AVATAR_SIZE } px` ,
157- '--stackSize-wide' : `${ size . wide || DEFAULT_AVATAR_SIZE } px` ,
341+ if ( enabled ) {
342+ return {
343+ '--stackSize-narrow' : `${ size . narrow || DEFAULT_AVATAR_SIZE } px` ,
344+ '--stackSize-regular' : `${ size . regular || DEFAULT_AVATAR_SIZE } px` ,
345+ '--stackSize-wide' : `${ size . wide || DEFAULT_AVATAR_SIZE } px` ,
346+ }
158347 }
348+
349+ return getBreakpointDeclarations (
350+ size ,
351+ '--avatar-stack-size' as keyof React . CSSProperties ,
352+ value => `${ value || DEFAULT_AVATAR_SIZE } px` ,
353+ )
159354 }
160355
161356 // if the `size` prop is set and not responsive, it is a number, so we can just set the `--avatar-stack-size` CSS variable to that number
162357 return { '--avatar-stack-size' : `${ size } px` } as React . CSSProperties
163358 }
164359
165- const BaseComponentWrapper = toggleSxComponent ( 'div' )
360+ const avatarStackSx = merge < BetterCssProperties | BetterSystemStyleObject > (
361+ ! enabled && getResponsiveAvatarSizeStyles ( ) ,
362+ sxProp as SxProp ,
363+ )
166364
167365 return (
168- < BaseComponentWrapper
169- data-avatar-count = { count > 3 ? '3+' : count }
170- data-align-right = { alignRight ? '' : undefined }
171- data-responsive = { ! size || isResponsiveValue ( size ) ? '' : undefined }
172- className = { clsx ( wrapperClassNames , classes . AvatarStack ) }
173- style = { { ...getResponsiveAvatarSizeStyles ( ) , ...style } }
174- sx = { sxProp }
366+ < AvatarStackWrapper
367+ count = { enabled ? undefined : count }
368+ data-avatar-count = { enabled ? ( count > 3 ? '3+' : count ) : undefined }
369+ data-align-right = { enabled && alignRight ? '' : undefined }
370+ data-responsive = { enabled && ( ! size || isResponsiveValue ( size ) ) ? '' : undefined }
371+ className = { clsx ( wrapperClassNames , { [ classes . AvatarStack ] : enabled } ) }
372+ style = { enabled ? { ...getResponsiveAvatarSizeStyles ( ) , style} : style }
373+ sx = { avatarStackSx }
175374 >
176375 < AvatarStackBody
177376 disableExpand = { disableExpand }
178377 hasInteractiveChildren = { hasInteractiveChildren }
179378 stackContainer = { stackContainer }
180379 >
181380 { ' ' }
182- { transformChildren ( children ) }
381+ { transformChildren ( children , enabled ) }
183382 </ AvatarStackBody >
184- </ BaseComponentWrapper >
383+ </ AvatarStackWrapper >
185384 )
186385}
187386
0 commit comments