diff --git a/.changeset/dry-cups-reply.md b/.changeset/dry-cups-reply.md new file mode 100644 index 0000000000..bcd16cd4bb --- /dev/null +++ b/.changeset/dry-cups-reply.md @@ -0,0 +1,58 @@ +--- +"@radix-ui/react-avatar": minor +"radix-ui": minor +--- + +Added a new `mode` prop to `Avatar.Image` to allow for more control over the image rendering. + +- `mode="default"` (default): Matches the existing behavior by rendering an `img` element conditionally based on the loading state of an `Image` object constructed after hydration. +- `mode="native"`: Renders an `img` element unconditionally using its event handlers to update loading state. In `native` mode, you can use CSS to target the image element and style it based on its loading state. + ```tsx + + + + + + + ``` + ```css + .image-root { + position: relative; + } + .image[data-radix-avatar-loading-status]:not( + [data-radix-avatar-loading-status="loaded"] + ) { + /* hide the element visually until it's loaded to reveal the fallback */ + position: absolute; + inset: 0; + opacity: 0; + } + ``` +- `mode="custom"`: Allows for more control over the image rendering. The `render` prop must be provided to render the image. This mode is useful when you want to use a custom image component, such as framework-specific image components or a design-system implementation that uses non-standard props. + + ```tsx + ( + { + context.onLoadingStatusChange("loaded"); + }} + onError={() => { + context.onLoadingStatusChange("error"); + }} + /> + )} + /> + ``` diff --git a/apps/storybook/stories/avatar.stories.module.css b/apps/storybook/stories/avatar.stories.module.css index 95cfae0bbd..d14b0095ee 100644 --- a/apps/storybook/stories/avatar.stories.module.css +++ b/apps/storybook/stories/avatar.stories.module.css @@ -1,4 +1,5 @@ .root { + position: relative; /* ensures image/fallback is centered */ display: inline-flex; align-items: center; @@ -21,6 +22,13 @@ object-fit: cover; } +.image[data-radix-avatar-loading-status]:not([data-radix-avatar-loading-status='loaded']), +.image[data-loading-status]:not([data-loading-status='loaded']) { + opacity: 0; + position: absolute; + inset: 0; +} + .fallback { /* ensures content inside the fallback is centered */ width: 100%; diff --git a/apps/storybook/stories/avatar.stories.tsx b/apps/storybook/stories/avatar.stories.tsx index d9a87467ee..16156996d7 100644 --- a/apps/storybook/stories/avatar.stories.tsx +++ b/apps/storybook/stories/avatar.stories.tsx @@ -8,7 +8,7 @@ const src = 'https://picsum.photos/id/1005/400/400'; const srcAlternative = 'https://picsum.photos/id/1006/400/400'; const srcBroken = 'https://broken.link.com/broken-pic.jpg'; -export const Styled = () => ( +export const Basic = () => ( <>

Without image & with fallback

@@ -50,6 +50,83 @@ export const Styled = () => ( ); +export const WithNativeImage = () => ( + <> +

With image & with fallback

+ + + + JS + + + +

With image & with fallback (but broken src)

+ + + + + + + +

Changing image src

+ + {(src) => ( + + + + JS + + + )} + + +); + +export const WithCustomImage = () => ( + <> +

With image & with fallback

+ + ( + { + if (naturalWidth === 0 || naturalHeight === 0) { + context.onLoadingStatusChange('error'); + } else { + context.onLoadingStatusChange('loaded'); + } + }} + onError={() => { + context.onLoadingStatusChange('error'); + }} + /> + )} + /> + + + + + +); + export const Chromatic = () => ( <>

Without image & with fallback

@@ -114,3 +191,395 @@ function SourceChanger({ }, [sources, src]); return children(src); } + +// Stripped-down pseudo implementation of Next.js's Image component to +// demonstrate how to use the AvatarImage with a custom mode. Doesn't really +// matter if blur/etc works or not, just here to illustrate the concept. +// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/image-component.tsx +// MIT license, Copyright (c) 2025 Vercel, Inc. +interface ImageProps { + ref?: React.Ref; + sizes: string; + src: string; + alt: string; + onLoad?: (event: React.SyntheticEvent) => void; + onError?: (event: React.SyntheticEvent) => void; + onLoadingComplete?: (result: { naturalWidth: number; naturalHeight: number }) => void; + preload?: boolean; + priority?: boolean; + loading?: 'eager' | 'lazy'; + className?: string; + quality?: number; + width: number; + height: number; + fill?: boolean; + style?: React.CSSProperties; + placeholder?: 'empty' | 'blur'; + blurDataURL?: string; +} + +interface ImageConfig { + allSizes: number[]; + imageSizes: number[]; + deviceSizes: number[]; + qualities: number[]; +} + +const NextImage: React.FC = (props) => { + const onLoadRef = React.useRef(props.onLoad); + React.useEffect(() => { + onLoadRef.current = props.onLoad; + }, [props.onLoad]); + + const onLoadingCompleteRef = React.useRef(props.onLoadingComplete); + React.useEffect(() => { + onLoadingCompleteRef.current = props.onLoadingComplete; + }, [props.onLoadingComplete]); + + const config = React.useMemo(() => { + return { + allSizes: [32, 48, 64, 96, 128, 256, 384], + imageSizes: [32, 48, 64, 96, 128, 256, 384], + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + qualities: [75], + }; + }, []); + + const [blurComplete, setBlurComplete] = React.useState(false); + const { props: imgAttributes, meta: imgMeta } = getImgProps(props, { + imgConf: config, + blurComplete, + }); + const { loading, width, height, style, sizes, srcSet, ...rest } = imgAttributes; + const { placeholder, fill } = imgMeta; + + return ( + <> + {/* oxlint-disable-next-line jsx-a11y/alt-text */} + { + const currentImage = event.currentTarget; + handleLoading( + currentImage, + placeholder, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + ); + }} + onError={(event) => { + if (placeholder !== 'empty') { + // If the real image fails to load, this will still remove the placeholder. + setBlurComplete(true); + } + if (props.onError) { + props.onError(event); + } + }} + /> + {imgMeta.preload ? ( + + ) : null} + + ); +}; + +function getImgProps( + { + src, + sizes, + priority = false, + preload = false, + loading, + className, + quality = 100, + width, + height, + fill = false, + style, + onLoad, + onLoadingComplete, + placeholder = 'empty', + blurDataURL, + ...rest + }: ImageProps, + state: { + imgConf: ImageConfig; + blurComplete: boolean | undefined; + }, +): { + props: React.ComponentProps<'img'>; + meta: { + placeholder: 'empty' | 'blur'; + preload: boolean; + fill: boolean; + }; +} { + const { imgConf: config, blurComplete } = state; + + let blurWidth: number | undefined; + let blurHeight: number | undefined; + + const isLazy = !priority && !preload && (loading === 'lazy' || typeof loading === 'undefined'); + const imgStyle = Object.assign( + fill + ? { + position: 'absolute', + height: '100%', + width: '100%', + left: 0, + top: 0, + right: 0, + bottom: 0, + objectFit: 'cover', + objectPosition: '50% 50%', + } + : {}, + style, + ); + + const backgroundImage = + !blurComplete && placeholder !== 'empty' + ? placeholder === 'blur' + ? `url("data:image/svg+xml;charset=utf-8,${getImageBlurSvg({ + widthInt: width, + heightInt: height, + blurWidth, + blurHeight, + blurDataURL: blurDataURL || '', + objectFit: imgStyle.objectFit, + })}")` + : `url("${placeholder}")` // assume `data:image/` + : null; + + const backgroundSize = !['contain', 'cover', 'fill'].includes(imgStyle.objectFit!) + ? imgStyle.objectFit + : imgStyle.objectFit === 'fill' + ? '100% 100%' // the background-size equivalent of `fill` + : 'cover'; + + const placeholderStyle: React.CSSProperties = backgroundImage + ? { + backgroundSize, + backgroundPosition: imgStyle.objectPosition || '50% 50%', + backgroundRepeat: 'no-repeat', + backgroundImage, + } + : {}; + + const imgAttributes = generateImgAttrs({ + config, + src, + width, + quality, + sizes, + loader: ({ src, quality, width }) => `${src}?w=${width}&q=${quality}`, + }); + + const loadingFinal = isLazy ? 'lazy' : loading; + + const props: React.ComponentProps<'img'> = { + ...rest, + loading: loadingFinal, + width, + height, + className, + style: { ...imgStyle, ...placeholderStyle }, + sizes: imgAttributes.sizes, + srcSet: imgAttributes.srcSet, + src: imgAttributes.src, + }; + + return { props, meta: { placeholder, preload: preload || priority, fill } }; +} + +function getImageBlurSvg({ + widthInt, + heightInt, + blurWidth, + blurHeight, + blurDataURL, + objectFit, +}: { + widthInt?: number; + heightInt?: number; + blurWidth?: number; + blurHeight?: number; + blurDataURL: string; + objectFit?: string; +}): string { + const std = 20; + const svgWidth = blurWidth ? blurWidth * 40 : widthInt; + const svgHeight = blurHeight ? blurHeight * 40 : heightInt; + + const viewBox = svgWidth && svgHeight ? `viewBox='0 0 ${svgWidth} ${svgHeight}'` : ''; + const preserveAspectRatio = viewBox + ? 'none' + : objectFit === 'contain' + ? 'xMidYMid' + : objectFit === 'cover' + ? 'xMidYMid slice' + : 'none'; + + return `%3Csvg xmlns='http://www.w3.org/2000/svg' ${viewBox}%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='${preserveAspectRatio}' style='filter: url(%23b);' href='${blurDataURL}'/%3E%3C/svg%3E`; +} + +function generateImgAttrs({ + config, + src, + width, + quality, + sizes, + loader, +}: { + config: ImageConfig; + src: string; + width: number; + quality: number; + sizes: string; + loader: (props: { config: ImageConfig; src: string; quality: number; width: number }) => string; +}) { + const { widths, kind } = getWidths(config, width, sizes); + const last = widths.length - 1; + return { + sizes: !sizes && kind === 'w' ? '100vw' : sizes, + srcSet: widths + .map( + (w, i) => + `${loader({ config, src, quality, width: w })} ${kind === 'w' ? w : i + 1}${kind}`, + ) + .join(', '), + src: loader({ config, src, quality, width: widths[last]! }), + }; +} + +function getWidths( + config: { deviceSizes: number[]; imageSizes: number[]; qualities: number[]; allSizes: number[] }, + width: number | undefined, + sizes: string | undefined, +): { widths: number[]; kind: 'w' | 'x' } { + const { deviceSizes, allSizes } = config; + if (sizes) { + // Find all the "vw" percent sizes used in the sizes prop + const viewportWidthRe = /(^|\s)(1?\d?\d)vw/g; + const percentSizes = []; + for (let match; (match = viewportWidthRe.exec(sizes)); match) { + percentSizes.push(parseInt(match[2]!)); + } + if (percentSizes.length) { + const smallestRatio = Math.min(...percentSizes) * 0.01; + return { + widths: allSizes.filter((s) => s >= deviceSizes[0]! * smallestRatio), + kind: 'w', + }; + } + return { widths: allSizes, kind: 'w' }; + } + if (typeof width !== 'number') { + return { widths: deviceSizes, kind: 'w' }; + } + + const widths = [ + ...new Set( + // > This means that most OLED screens that say they are 3x resolution, + // > are actually 3x in the green color, but only 1.5x in the red and + // > blue colors. Showing a 3x resolution image in the app vs a 2x + // > resolution image will be visually the same, though the 3x image + // > takes significantly more data. Even true 3x resolution screens are + // > wasteful as the human eye cannot see that level of detail without + // > something like a magnifying glass. + // https://blog.twitter.com/engineering/en_us/topics/infrastructure/2019/capping-image-fidelity-on-ultra-high-resolution-devices.html + [width, width * 2 /*, width * 3*/].map( + (w) => allSizes.find((p) => p >= w) || allSizes[allSizes.length - 1]!, + ), + ), + ]; + return { widths, kind: 'x' }; +} + +// See https://stackoverflow.com/q/39777833/266535 for why we use this ref +// handler instead of the img's onLoad attribute. +function handleLoading( + img: HTMLImageElement, + placeholder: 'empty' | 'blur', + onLoadRef: React.RefObject<((event: React.SyntheticEvent) => void) | undefined>, + onLoadingCompleteRef: React.RefObject< + ((result: { naturalWidth: number; naturalHeight: number }) => void) | undefined + >, + setBlurComplete: (b: boolean) => void, +) { + const src = img?.src; + if (!img || (img as any)['data-loaded-src'] === src) { + return; + } + (img as any)['data-loaded-src'] = src; + const p = 'decode' in img ? img.decode() : Promise.resolve(); + p.catch(() => {}).then(() => { + if (!img.parentElement || !img.isConnected) { + // Exit early in case of race condition: + // - onload() is called + // - decode() is called but incomplete + // - unmount is called + // - decode() completes + return; + } + if (placeholder !== 'empty') { + setBlurComplete(true); + } + if (onLoadRef?.current) { + // Since we don't have the SyntheticEvent here, + // we must create one with the same shape. + // See https://reactjs.org/docs/events.html + const event = new Event('load'); + Object.defineProperty(event, 'target', { writable: false, value: img }); + let prevented = false; + let stopped = false; + onLoadRef.current({ + ...event, + nativeEvent: event, + currentTarget: img, + target: img, + isDefaultPrevented: () => prevented, + isPropagationStopped: () => stopped, + persist: () => {}, + preventDefault: () => { + prevented = true; + event.preventDefault(); + }, + stopPropagation: () => { + stopped = true; + event.stopPropagation(); + }, + }); + } + if (onLoadingCompleteRef?.current) { + onLoadingCompleteRef.current(img); + } + }); +} diff --git a/packages/react/avatar/package.json b/packages/react/avatar/package.json index d226bbd70c..5d3f76acb6 100644 --- a/packages/react/avatar/package.json +++ b/packages/react/avatar/package.json @@ -35,10 +35,11 @@ "build": "radix-build" }, "dependencies": { + "@radix-ui/primitive": "workspace:*", + "@radix-ui/react-compose-refs": "workspace:*", "@radix-ui/react-context": "workspace:*", "@radix-ui/react-primitive": "workspace:*", "@radix-ui/react-use-callback-ref": "workspace:*", - "@radix-ui/react-use-is-hydrated": "workspace:*", "@radix-ui/react-use-layout-effect": "workspace:*" }, "devDependencies": { diff --git a/packages/react/avatar/src/avatar.test.tsx b/packages/react/avatar/src/avatar.test.tsx index 28d52accc1..8e0e2c5f5e 100644 --- a/packages/react/avatar/src/avatar.test.tsx +++ b/packages/react/avatar/src/avatar.test.tsx @@ -1,6 +1,6 @@ import { axe } from 'vitest-axe'; import type { RenderResult } from '@testing-library/react'; -import { cleanup, render, waitFor } from '@testing-library/react'; +import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'; import * as Avatar from './avatar'; import * as React from 'react'; import { renderToString } from 'react-dom/server'; @@ -402,6 +402,83 @@ describe('given an Avatar with multiple images (development)', () => { }); }); +describe('given an Avatar with a native image', () => { + afterEach(cleanup); + + const ui = (showImage: boolean) => ( + + {FALLBACK_TEXT} + {showImage ? : null} + + ); + + it('renders the image element unconditionally', () => { + const rendered = render(ui(true)); + expect(rendered.queryByRole('img')).toBeInTheDocument(); + }); + + it('resolves to "loaded" for a cached image', async () => { + // Mirror a cached image + const completeSpy = vi + .spyOn(window.HTMLImageElement.prototype, 'complete', 'get') + .mockReturnValue(true); + const naturalWidthSpy = vi + .spyOn(window.HTMLImageElement.prototype, 'naturalWidth', 'get') + .mockReturnValue(300); + + const rendered = render(ui(true)); + const image = rendered.getByRole('img'); + + await waitFor(() => + expect(image).toHaveAttribute('data-radix-avatar-loading-status', 'loaded'), + ); + expect(rendered.queryByText(FALLBACK_TEXT)).not.toBeInTheDocument(); + + completeSpy.mockRestore(); + naturalWidthSpy.mockRestore(); + }); + + it('resolves to "error" for a cached image with no natural size', async () => { + const completeSpy = vi + .spyOn(window.HTMLImageElement.prototype, 'complete', 'get') + .mockReturnValue(true); + const naturalWidthSpy = vi + .spyOn(window.HTMLImageElement.prototype, 'naturalWidth', 'get') + .mockReturnValue(0); + + const rendered = render(ui(true)); + const image = rendered.getByRole('img'); + + await waitFor(() => expect(image).toHaveAttribute('data-radix-avatar-loading-status', 'error')); + expect(rendered.queryByText(FALLBACK_TEXT)).toBeInTheDocument(); + + completeSpy.mockRestore(); + naturalWidthSpy.mockRestore(); + }); + + it('should render the fallback again after a loaded image unmounts', async () => { + const completeSpy = vi + .spyOn(window.HTMLImageElement.prototype, 'complete', 'get') + .mockReturnValue(true); + const naturalWidthSpy = vi + .spyOn(window.HTMLImageElement.prototype, 'naturalWidth', 'get') + .mockReturnValue(300); + + const rendered = render(ui(true)); + const image = rendered.getByRole('img'); + + fireEvent.load(image); + await waitFor(() => expect(rendered.queryByText(FALLBACK_TEXT)).not.toBeInTheDocument()); + + rendered.rerender(ui(false)); + expect(rendered.queryByRole('img')).not.toBeInTheDocument(); + expect(rendered.queryByText(FALLBACK_TEXT)).toBeInTheDocument(); + + completeSpy.mockRestore(); + naturalWidthSpy.mockRestore(); + }); +}); + class MockImage extends EventTarget { _src: string = ''; diff --git a/packages/react/avatar/src/avatar.tsx b/packages/react/avatar/src/avatar.tsx index c5c5b816c0..2d0b554274 100644 --- a/packages/react/avatar/src/avatar.tsx +++ b/packages/react/avatar/src/avatar.tsx @@ -1,10 +1,10 @@ import * as React from 'react'; -import { createContextScope } from '@radix-ui/react-context'; +import { createContextScope, type Scope } from '@radix-ui/react-context'; import { useCallbackRef } from '@radix-ui/react-use-callback-ref'; import { useLayoutEffect } from '@radix-ui/react-use-layout-effect'; import { Primitive } from '@radix-ui/react-primitive'; - -import type { Scope } from '@radix-ui/react-context'; +import { composeEventHandlers } from '@radix-ui/primitive'; +import { useComposedRefs } from '@radix-ui/react-compose-refs'; /* ------------------------------------------------------------------------------------------------- * Avatar @@ -65,45 +65,191 @@ const IMAGE_NAME = 'AvatarImage'; type AvatarImageElement = React.ComponentRef; type PrimitiveImageProps = React.ComponentPropsWithoutRef; -interface AvatarImageProps extends PrimitiveImageProps { +type AvatarImageMode = 'default' | 'native' | 'custom'; +interface AvatarImageCommonProps extends PrimitiveImageProps { onLoadingStatusChange?: (status: ImageLoadingStatus) => void; } +interface AvatarImageDefaultModeProps extends AvatarImageCommonProps { + mode?: Exclude; + render?: never; +} +interface AvatarImageCustomModeProps extends AvatarImageCommonProps { + mode: 'custom'; + render: (args: { + props: PrimitiveImageProps; + ref: React.Ref | undefined; + context: { + loadingStatus: ImageLoadingStatus; + onLoadingStatusChange: (status: ImageLoadingStatus) => void; + }; + }) => React.ReactNode; +} + +type AvatarImageProps = AvatarImageDefaultModeProps | AvatarImageCustomModeProps; const AvatarImage = React.forwardRef( (props: ScopedProps, forwardedRef) => { - const { __scopeAvatar, src, onLoadingStatusChange, ...imageProps } = props; - const context = useAvatarContext(IMAGE_NAME, __scopeAvatar); - useUpdateImageCount(context.setImageCount); - - const imageLoadingStatus = useImageLoadingStatus(src, { - referrerPolicy: imageProps.referrerPolicy, - crossOrigin: imageProps.crossOrigin, - loadingStatus: context.imageLoadingStatus, - setLoadingStatus: context.setImageLoadingStatus, - }); - const handleLoadingStatusChange = useCallbackRef((status: ImageLoadingStatus) => { - onLoadingStatusChange?.(status); - }); - - const loadingStatusRef = React.useRef(imageLoadingStatus); - - useLayoutEffect(() => { - const previousLoadingStatus = loadingStatusRef.current; - loadingStatusRef.current = imageLoadingStatus; - - if (imageLoadingStatus !== previousLoadingStatus) { - handleLoadingStatusChange(imageLoadingStatus); - } - }, [imageLoadingStatus, handleLoadingStatusChange]); - - return imageLoadingStatus === 'loaded' ? ( - - ) : null; + const { mode = 'default', render, ...imageProps } = props; + switch (mode) { + case 'default': + return ; + case 'native': + return ; + case 'custom': + if (render == null) { + throw new Error('AvatarImage: `render` prop is required when mode is `custom`'); + } + + return ; + + default: + mode satisfies never; + return ; + } }, ); AvatarImage.displayName = IMAGE_NAME; +const AvatarImageImpl = React.forwardRef< + AvatarImageElement, + Omit +>((props: ScopedProps>, forwardedRef) => { + const { __scopeAvatar, src, onLoadingStatusChange, ...imageProps } = props; + const context = useAvatarContext(IMAGE_NAME, __scopeAvatar); + useUpdateImageCount(context.setImageCount); + + const imageLoadingStatus = useImageLoadingStatus(src, { + referrerPolicy: imageProps.referrerPolicy, + crossOrigin: imageProps.crossOrigin, + loadingStatus: context.imageLoadingStatus, + setLoadingStatus: context.setImageLoadingStatus, + }); + const handleLoadingStatusChange = useCallbackRef((status: ImageLoadingStatus) => { + onLoadingStatusChange?.(status); + }); + + const loadingStatusRef = React.useRef(imageLoadingStatus); + + useLayoutEffect(() => { + const previousLoadingStatus = loadingStatusRef.current; + loadingStatusRef.current = imageLoadingStatus; + + if (imageLoadingStatus !== previousLoadingStatus) { + handleLoadingStatusChange(imageLoadingStatus); + } + }, [imageLoadingStatus, handleLoadingStatusChange]); + + return imageLoadingStatus === 'loaded' ? ( + + ) : null; +}); + +AvatarImageImpl.displayName = IMAGE_NAME + 'Impl'; + +const AvatarImageNative = React.forwardRef< + AvatarImageElement, + Omit +>((props: ScopedProps>, forwardedRef) => { + const { __scopeAvatar, src, onLoadingStatusChange, ...imageProps } = props; + const context = useAvatarContext(IMAGE_NAME, __scopeAvatar); + useUpdateImageCount(context.setImageCount); + + const { imageLoadingStatus, setImageLoadingStatus } = context; + const loadingStatusRef = React.useRef(imageLoadingStatus); + + const handleLoadingStatusChange = useCallbackRef((status: ImageLoadingStatus) => { + onLoadingStatusChange?.(status); + }); + + useLayoutEffect(() => { + const previousLoadingStatus = loadingStatusRef.current; + loadingStatusRef.current = imageLoadingStatus; + if (imageLoadingStatus !== previousLoadingStatus) { + handleLoadingStatusChange(imageLoadingStatus); + } + }, [imageLoadingStatus, handleLoadingStatusChange]); + + return ( + { + if (!src) { + // an image without an src will never update its loading status from `error` + return; + } + + if (node) { + setImageLoadingStatus(getImageLoadingStatus(node)); + } + + if (Number.parseInt(React.version, 10) >= 19) { + return () => { + setImageLoadingStatus('idle'); + }; + } else if (!node) { + setImageLoadingStatus('idle'); + } + }, + [setImageLoadingStatus, src], + ), + )} + src={src} + onError={composeEventHandlers(imageProps.onError, () => + context.setImageLoadingStatus('error'), + )} + onLoad={composeEventHandlers(imageProps.onLoad, (event) => { + if (!src) { + // an image without an src will never update its loading status from `error` + return; + } + + return context.setImageLoadingStatus(getImageLoadingStatus(event.currentTarget)); + })} + /> + ); +}); +AvatarImageNative.displayName = IMAGE_NAME + 'Native'; + +const AvatarImageCustom = React.forwardRef< + AvatarImageElement, + Omit +>((props: ScopedProps>, forwardedRef) => { + const { __scopeAvatar, render, onLoadingStatusChange, ...imageProps } = props; + const context = useAvatarContext(IMAGE_NAME, __scopeAvatar); + useUpdateImageCount(context.setImageCount); + + const handleLoadingStatusChange = useCallbackRef((status: ImageLoadingStatus) => { + onLoadingStatusChange?.(status); + }); + + const { imageLoadingStatus, setImageLoadingStatus } = context; + const loadingStatusRef = React.useRef(imageLoadingStatus); + + useLayoutEffect(() => { + const previousLoadingStatus = loadingStatusRef.current; + loadingStatusRef.current = imageLoadingStatus; + if (imageLoadingStatus !== previousLoadingStatus) { + handleLoadingStatusChange(imageLoadingStatus); + } + }, [imageLoadingStatus, handleLoadingStatusChange]); + + return render({ + context: { + loadingStatus: imageLoadingStatus, + onLoadingStatusChange: setImageLoadingStatus, + }, + props: imageProps, + ref: forwardedRef, + }); +}); + +AvatarImageCustom.displayName = IMAGE_NAME + 'Custom'; + /* ------------------------------------------------------------------------------------------------- * AvatarFallback * -----------------------------------------------------------------------------------------------*/ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5715fa5193..80cc20ae61 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -475,6 +475,12 @@ importers: packages/react/avatar: dependencies: + '@radix-ui/primitive': + specifier: workspace:* + version: link:../../core/primitive + '@radix-ui/react-compose-refs': + specifier: workspace:* + version: link:../compose-refs '@radix-ui/react-context': specifier: workspace:* version: link:../context @@ -484,9 +490,6 @@ importers: '@radix-ui/react-use-callback-ref': specifier: workspace:* version: link:../use-callback-ref - '@radix-ui/react-use-is-hydrated': - specifier: workspace:* - version: link:../use-is-hydrated '@radix-ui/react-use-layout-effect': specifier: workspace:* version: link:../use-layout-effect