diff --git a/.yarn/versions/48669fbc.yml b/.yarn/versions/48669fbc.yml new file mode 100644 index 000000000..834dea45c --- /dev/null +++ b/.yarn/versions/48669fbc.yml @@ -0,0 +1,3 @@ +releases: + "@radix-ui/react-avatar": minor + primitives: patch diff --git a/packages/react/avatar/src/Avatar.stories.tsx b/packages/react/avatar/src/Avatar.stories.tsx index 0ee66a2bf..c1c5d0fc4 100644 --- a/packages/react/avatar/src/Avatar.stories.tsx +++ b/packages/react/avatar/src/Avatar.stories.tsx @@ -4,8 +4,19 @@ import * as Avatar from '@radix-ui/react-avatar'; export default { title: 'Components/Avatar' }; const src = 'https://picsum.photos/id/1005/400/400'; +const otherSrc = 'https://picsum.photos/id/1006/400/400'; const srcBroken = 'https://broken.link.com/broken-pic.jpg'; +const FakeFrameworkImage = (props: any) => { + console.log(props); + + return ( +
+ framework test +
+ ); +}; + export const Styled = () => ( <>

Without image & with fallback

@@ -33,6 +44,36 @@ export const Styled = () => ( + +

With image framework component

+ + + + + + + + + +

With image framework component & with fallback (but broken src)

+ + + + + + + + ); @@ -58,6 +99,36 @@ export const Chromatic = () => ( + +

With image framework component

+ + + + + + + + + +

With image framework component & with fallback (but broken src)

+ + + + + + + + ); Chromatic.parameters = { chromatic: { disable: false, delay: 1000 } }; diff --git a/packages/react/avatar/src/Avatar.test.tsx b/packages/react/avatar/src/Avatar.test.tsx index 4e71f84ed..1ac65aead 100644 --- a/packages/react/avatar/src/Avatar.test.tsx +++ b/packages/react/avatar/src/Avatar.test.tsx @@ -7,6 +7,8 @@ const ROOT_TEST_ID = 'avatar-root'; const FALLBACK_TEXT = 'AB'; const IMAGE_ALT_TEXT = 'Fake Avatar'; const DELAY = 300; +const FRAMEWORK_IMAGE_TEST_ID = 'framework-image-component'; +const FRAMEWORK_IMAGE_ALT_TEXT = 'framework test'; describe('given an Avatar with fallback and no image', () => { let rendered: RenderResult; @@ -33,6 +35,7 @@ describe('given an Avatar with fallback and a working image', () => { (window.Image as any) = class MockImage { onload: () => void = () => {}; src: string = ''; + constructor() { setTimeout(() => { this.onload(); @@ -101,6 +104,66 @@ describe('given an Avatar with fallback and delayed render', () => { }); }); +describe('given an Avatar with fallback and child image', () => { + let rendered: RenderResult; + let image: HTMLElement | null = null; + const orignalGlobalImage = window.Image; + + beforeAll(() => { + (window.Image as any) = class MockImage { + onload: () => void = () => {}; + src: string = ''; + + constructor() { + setTimeout(() => { + this.onload(); + }, DELAY); + return this; + } + }; + }); + + afterAll(() => { + window.Image = orignalGlobalImage; + }); + + beforeEach(() => { + rendered = render( + + + {FRAMEWORK_IMAGE_ALT_TEXT} + + {FALLBACK_TEXT} + + ); + console.log(rendered); + }); + + it('should render the image after it has loaded', async () => { + image = await rendered.findByRole('img'); + expect(image).toBeInTheDocument(); + }); + + it('should have alt text on the image', async () => { + image = await rendered.findByAltText(FRAMEWORK_IMAGE_ALT_TEXT); + expect(image).toBeInTheDocument(); + }); + + it('should render the fallback initially', () => { + const fallback = rendered.queryByText(FALLBACK_TEXT); + expect(fallback).toBeInTheDocument(); + }); + + it('should render the image framework component', () => { + const frameworkImage = rendered.queryByTestId(FRAMEWORK_IMAGE_TEST_ID); + expect(frameworkImage).toBeInTheDocument(); + }); +}); + describe('given an Avatar with an image that only works when referrerPolicy=no-referrer', () => { let rendered: RenderResult; const orignalGlobalImage = window.Image; diff --git a/packages/react/avatar/src/Avatar.tsx b/packages/react/avatar/src/Avatar.tsx index 53841b7f0..2fe160388 100644 --- a/packages/react/avatar/src/Avatar.tsx +++ b/packages/react/avatar/src/Avatar.tsx @@ -62,8 +62,13 @@ const AvatarImage = React.forwardRef( (props: ScopedProps, forwardedRef) => { const { __scopeAvatar, src, onLoadingStatusChange = () => {}, ...imageProps } = props; const context = useAvatarContext(IMAGE_NAME, __scopeAvatar); - const imageLoadingStatus = useImageLoadingStatus(src, imageProps.referrerPolicy); + const { loadingStatus: imageLoadingStatus, setLoadingStatus } = useImageLoadingStatus( + src, + props.asChild, + imageProps.referrerPolicy + ); const handleLoadingStatusChange = useCallbackRef((status: ImageLoadingStatus) => { + setLoadingStatus(status); onLoadingStatusChange(status); context.onImageLoadingStatusChange(status); }); @@ -74,6 +79,35 @@ const AvatarImage = React.forwardRef( } }, [imageLoadingStatus, handleLoadingStatusChange]); + if (props.asChild && props.children) { + if (imageLoadingStatus === 'error') { + return null; + } + + // Ensure children is a valid React element + const child = React.Children.only(props.children) as React.ReactElement; + + const { asChild, children, ...restProps } = props; + + const childProps = child.props; + + // Clone the child to add onLoad and onError event listeners + return React.cloneElement(child, { + ...restProps, + ...child.props, + onError: (event: React.SyntheticEvent) => { + console.log('error'); + handleLoadingStatusChange('error'); + if (childProps.onError) childProps.onError(event); + }, + onLoad: (event: React.SyntheticEvent) => { + console.log('loaded'); + handleLoadingStatusChange('loaded'); + if (childProps.onLoad) childProps.onLoad(event); + }, + }); + } + return imageLoadingStatus === 'loaded' ? ( ) : null; @@ -89,6 +123,7 @@ AvatarImage.displayName = IMAGE_NAME; const FALLBACK_NAME = 'AvatarFallback'; type AvatarFallbackElement = React.ElementRef; + interface AvatarFallbackProps extends PrimitiveSpanProps { delayMs?: number; } @@ -116,10 +151,18 @@ AvatarFallback.displayName = FALLBACK_NAME; /* -----------------------------------------------------------------------------------------------*/ -function useImageLoadingStatus(src?: string, referrerPolicy?: React.HTMLAttributeReferrerPolicy) { +function useImageLoadingStatus( + src?: string, + bypass?: boolean, + referrerPolicy?: React.HTMLAttributeReferrerPolicy +) { const [loadingStatus, setLoadingStatus] = React.useState('idle'); useLayoutEffect(() => { + if (bypass) { + setLoadingStatus('idle'); + return; + } if (!src) { setLoadingStatus('error'); return; @@ -144,9 +187,9 @@ function useImageLoadingStatus(src?: string, referrerPolicy?: React.HTMLAttribut return () => { isMounted = false; }; - }, [src, referrerPolicy]); + }, [src, bypass, referrerPolicy]); - return loadingStatus; + return { loadingStatus, setLoadingStatus }; } const Root = Avatar; const Image = AvatarImage;