Skip to content

Commit 810a385

Browse files
authored
feat: Conditional image sources (#9335)
* feat: Conditional image sources * Fix
1 parent 3d374c5 commit 810a385

File tree

8 files changed

+193
-46
lines changed

8 files changed

+193
-46
lines changed

packages/@react-spectrum/s2/src/Image.tsx

Lines changed: 82 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {ColorSchemeContext} from './Provider';
12
import {ContextValue, SlotProps} from 'react-aria-components';
23
import {createContext, ForwardedRef, forwardRef, HTMLAttributeReferrerPolicy, JSX, ReactNode, useCallback, useContext, useMemo, useReducer, useRef, version} from 'react';
34
import {DefaultImageGroup, ImageGroup} from './ImageCoordinator';
@@ -9,9 +10,46 @@ import {UnsafeStyles} from './style-utils';
910
import {useLayoutEffect} from '@react-aria/utils';
1011
import {useSpectrumContextProps} from './useSpectrumContextProps';
1112

13+
export interface ImageSource {
14+
/**
15+
* A comma-separated list of image URLs and descriptors.
16+
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#srcset).
17+
*/
18+
srcSet?: string | undefined,
19+
/**
20+
* The color scheme for this image source. Unlike `media`, this respects the `Provider` color scheme setting.
21+
*/
22+
colorScheme?: 'light' | 'dark',
23+
/**
24+
* A media query describing when the source should render.
25+
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#media).
26+
*/
27+
media?: string | undefined,
28+
/**
29+
* A list of source sizes that describe the final rendered width of the image.
30+
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#sizes).
31+
*/
32+
sizes?: string | undefined,
33+
/**
34+
* The mime type of the image.
35+
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#type).
36+
*/
37+
type?: string | undefined,
38+
/**
39+
* The intrinsic width of the image.
40+
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#width).
41+
*/
42+
width?: number,
43+
/**
44+
* The intrinsic height of the image.
45+
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#height).
46+
*/
47+
height?: number
48+
}
49+
1250
export interface ImageProps extends UnsafeStyles, SlotProps {
13-
/** The URL of the image. */
14-
src?: string,
51+
/** The URL of the image or a list of conditional sources. */
52+
src?: string | ImageSource[],
1553
// TODO
1654
// srcSet?: string,
1755
// sizes?: string,
@@ -61,10 +99,6 @@ export interface ImageProps extends UnsafeStyles, SlotProps {
6199
* If not provided, the default image group is used.
62100
*/
63101
group?: ImageGroup,
64-
/**
65-
* Child `<source>` elements defining alternate versions of an image for different display/device scenarios.
66-
*/
67-
children?: ReactNode,
68102
/**
69103
* Associates the image with a microdata object.
70104
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/itemprop).
@@ -159,7 +193,7 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
159193
[props, domRef] = useSpectrumContextProps(props, domRef, ImageContext);
160194

161195
let {
162-
src = '',
196+
src: srcProp = '',
163197
styles,
164198
UNSAFE_className = '',
165199
UNSAFE_style,
@@ -177,16 +211,17 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
177211
slot,
178212
width,
179213
height,
180-
children,
181214
itemProp
182215
} = props;
183216
let hidden = (props as ImageContextValue).hidden;
217+
let colorScheme = useContext(ColorSchemeContext);
218+
let cacheKey = useMemo(() => typeof srcProp === 'object' ? JSON.stringify(srcProp) : srcProp, [srcProp]);
184219

185220
let {revealAll, register, unregister, load} = useContext(group);
186-
let [{state, src: lastSrc, loadTime}, dispatch] = useReducer(reducer, src, createState);
221+
let [{state, src: lastCacheKey, loadTime}, dispatch] = useReducer(reducer, cacheKey, createState);
187222

188-
if (src !== lastSrc && !hidden) {
189-
dispatch({type: 'update', src});
223+
if (cacheKey !== lastCacheKey && !hidden) {
224+
dispatch({type: 'update', src: cacheKey});
190225
}
191226

192227
if (state === 'loaded' && revealAll && !hidden) {
@@ -199,21 +234,21 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
199234
return;
200235
}
201236

202-
register(src);
237+
register(cacheKey);
203238
return () => {
204-
unregister(src);
239+
unregister(cacheKey);
205240
};
206-
}, [hidden, register, unregister, src]);
241+
}, [hidden, register, unregister, cacheKey]);
207242

208243
let onLoad = useCallback(() => {
209-
load(src);
244+
load(cacheKey);
210245
dispatch({type: 'loaded'});
211-
}, [load, src]);
246+
}, [load, cacheKey]);
212247

213248
let onError = useCallback(() => {
214249
dispatch({type: 'error'});
215-
unregister(src);
216-
}, [unregister, src]);
250+
unregister(cacheKey);
251+
}, [unregister, cacheKey]);
217252

218253
let isSkeleton = useIsSkeleton();
219254
let isAnimating = isSkeleton || state === 'loading' || state === 'loaded';
@@ -223,15 +258,20 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
223258
return;
224259
}
225260

261+
// In React act environments, run immediately.
262+
// @ts-ignore
263+
let isTestEnv = typeof IS_REACT_ACT_ENVIRONMENT === 'boolean' ? IS_REACT_ACT_ENVIRONMENT : typeof jest !== 'undefined';
264+
let runTask = isTestEnv ? fn => fn() : queueMicrotask;
265+
226266
// If the image is already loaded, update state immediately instead of waiting for onLoad.
227267
let img = imgRef.current;
228268
if (state === 'loading' && img?.complete) {
229269
if (img.naturalWidth === 0 && img.naturalHeight === 0) {
230270
// Queue a microtask so we don't hit React's update limit.
231271
// TODO: is this necessary?
232-
queueMicrotask(onError);
272+
runTask(onError);
233273
} else {
234-
queueMicrotask(onLoad);
274+
runTask(onLoad);
235275
}
236276
}
237277

@@ -253,7 +293,7 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
253293
let img = (
254294
<img
255295
{...getFetchPriorityProp(fetchPriority)}
256-
src={src || undefined}
296+
src={typeof srcProp === 'string' && srcProp ? srcProp : undefined}
257297
alt={alt}
258298
crossOrigin={crossOrigin}
259299
decoding={decoding}
@@ -268,10 +308,28 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
268308
className={imgStyles({isRevealed, isTransitioning})} />
269309
);
270310

271-
if (children) {
311+
if (Array.isArray(srcProp)) {
272312
img = (
273313
<picture>
274-
{children}
314+
{srcProp.map((source, i) => {
315+
let {colorScheme: sourceColorScheme, ...sourceProps} = source;
316+
if (sourceColorScheme) {
317+
if (!colorScheme || colorScheme === 'light dark') {
318+
return (
319+
<source
320+
key={i}
321+
{...sourceProps}
322+
media={`${source.media ? `${source.media} and ` : ''}(prefers-color-scheme: ${sourceColorScheme})`} />
323+
);
324+
}
325+
326+
return sourceColorScheme === colorScheme
327+
? <source key={i} {...sourceProps} />
328+
: null;
329+
} else {
330+
return <source key={i} {...sourceProps} />;
331+
}
332+
})}
275333
{img}
276334
</picture>
277335
);
@@ -287,7 +345,7 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
287345
{!errorState && img}
288346
</div>
289347
);
290-
}, [slot, hidden, domRef, UNSAFE_style, UNSAFE_className, styles, isAnimating, errorState, src, alt, crossOrigin, decoding, fetchPriority, loading, referrerPolicy, width, height, onLoad, onError, isRevealed, isTransitioning, children, itemProp]);
348+
}, [slot, hidden, domRef, UNSAFE_style, UNSAFE_className, styles, isAnimating, errorState, alt, crossOrigin, decoding, fetchPriority, loading, referrerPolicy, width, height, onLoad, onError, isRevealed, isTransitioning, srcProp, itemProp, colorScheme]);
291349
});
292350

293351
function getFetchPriorityProp(fetchPriority?: 'high' | 'low' | 'auto'): Record<string, string | undefined> {

packages/@react-spectrum/s2/src/Skeleton.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ export function useLoadingAnimation(isAnimating: boolean): (element: HTMLElement
2222
let animationRef = useRef<Animation | null>(null);
2323
let reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
2424
return useCallback((element: HTMLElement | null) => {
25-
if (isAnimating && !animationRef.current && element && !reduceMotion) {
25+
if (isAnimating && !animationRef.current && element && !reduceMotion && typeof element.animate === 'function') {
2626
// Use web animation API instead of CSS animations so that we can
2727
// synchronize it between all loading elements on the page (via startTime).
2828
animationRef.current = element.animate(
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {Image, Provider} from '../src';
14+
import {render} from '@react-spectrum/test-utils-internal';
15+
16+
describe('Image', () => {
17+
it('should support conditional sources', async () => {
18+
let {getByRole} = render(
19+
<Image
20+
alt="test"
21+
src={[
22+
{srcSet: 'foo.png', type: 'image/png', colorScheme: 'light'},
23+
{srcSet: 'bar.png', colorScheme: 'dark', media: '(width >= 500px)'},
24+
{srcSet: 'default.png'}
25+
]} />
26+
);
27+
28+
let img = getByRole('img');
29+
let picture = img.parentElement!;
30+
expect(picture.tagName).toBe('PICTURE');
31+
let sources = picture.querySelectorAll('source');
32+
33+
expect(sources).toHaveLength(3);
34+
expect(sources[0]).toHaveAttribute('srcset', 'foo.png');
35+
expect(sources[0]).toHaveAttribute('type', 'image/png');
36+
expect(sources[0]).toHaveAttribute('media', '(prefers-color-scheme: light)');
37+
expect(sources[1]).toHaveAttribute('srcset', 'bar.png');
38+
expect(sources[1]).toHaveAttribute('media', '(width >= 500px) and (prefers-color-scheme: dark)');
39+
expect(sources[2]).toHaveAttribute('srcset', 'default.png');
40+
});
41+
42+
it('should support conditional sources with Provider colorScheme override', async () => {
43+
let {getByRole} = render(
44+
<Provider colorScheme="dark">
45+
<Image
46+
alt="test"
47+
src={[
48+
{srcSet: 'foo.png', type: 'image/png', colorScheme: 'light'},
49+
{srcSet: 'bar.png', colorScheme: 'dark', media: '(width >= 500px)'},
50+
{srcSet: 'default.png'}
51+
]} />
52+
</Provider>
53+
);
54+
55+
let img = getByRole('img');
56+
let picture = img.parentElement!;
57+
expect(picture.tagName).toBe('PICTURE');
58+
let sources = picture.querySelectorAll('source');
59+
60+
expect(sources).toHaveLength(2);
61+
expect(sources[0]).toHaveAttribute('srcset', 'bar.png');
62+
expect(sources[0]).toHaveAttribute('media', '(width >= 500px)');
63+
expect(sources[1]).toHaveAttribute('srcset', 'default.png');
64+
});
65+
});

packages/dev/s2-docs/pages/s2/Image.mdx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,29 @@ import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
2121
styles={style({width: 400, maxWidth: 'full', borderRadius: 'default'})} />
2222
```
2323

24+
## Conditional sources
25+
26+
Set the `src` prop to an array of objects describing conditional images, e.g. media queries or image formats. These accept the same props as the &lt;[source](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source)&gt; HTML element, as well as `colorScheme` to conditionally render images based on the [Provider](Provider) color scheme.
27+
28+
```tsx render type="s2" wide docs={docs.exports.Provider} links={docs.links} props={['colorScheme']}
29+
"use client";
30+
import {Image, Provider} from '@react-spectrum/s2';
31+
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
32+
33+
<Provider/* PROPS */>
34+
<Image
35+
/*- begin highlight -*/
36+
src={[
37+
{colorScheme: 'light', srcSet: 'https://images.unsplash.com/photo-1722172118908-1a97c312ce8c?q=80&w=600&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'},
38+
{colorScheme: 'dark', srcSet: 'https://images.unsplash.com/photo-1722233987129-61dc344db8b6?q=80&w=600&auto=format&fit=crop&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D'}
39+
]}
40+
/*- end highlight -*/
41+
alt="Conditional image"
42+
styles={style({height: 300, maxWidth: 'full', borderRadius: 'default'})} />
43+
</Provider>
44+
```
45+
46+
2447
## Error state
2548

2649
Use `renderError` to display a custom error UI when an image fails to load.

packages/dev/s2-docs/src/ComponentCard.tsx

Lines changed: 12 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -408,15 +408,15 @@ function ComponentIllustration({name, href}: IllustrationProps) {
408408

409409
return (
410410
<Image
411-
src={light}
411+
src={[
412+
{srcSet: light, colorScheme: 'light'},
413+
{srcSet: dark, colorScheme: 'dark'}
414+
]}
412415
alt=""
413416
aria-hidden="true"
414417
width={960}
415418
height={600}
416-
styles={illustrationStyles}>
417-
<source srcSet={light} media="(prefers-color-scheme: light)" />
418-
<source srcSet={dark} media="(prefers-color-scheme: dark)" />
419-
</Image>
419+
styles={illustrationStyles} />
420420
);
421421
}
422422

@@ -443,16 +443,13 @@ export function ComponentCard({id, name, href, description, size, ...otherProps}
443443
preview = (
444444
<div className={illustrationContainer}>
445445
{/* Background gradient */}
446-
<picture>
447-
<source srcSet={BackgroundLight} media="(prefers-color-scheme: light)" />
448-
<source srcSet={BackgroundDark} media="(prefers-color-scheme: dark)" />
449-
<img
450-
src={BackgroundLight}
451-
alt=""
452-
aria-hidden="true"
453-
className={backgroundStyles}
454-
loading="lazy" />
455-
</picture>
446+
<Image
447+
alt=""
448+
styles={backgroundStyles}
449+
src={[
450+
{srcSet: BackgroundLight, colorScheme: 'light'},
451+
{srcSet: BackgroundDark, colorScheme: 'dark'}
452+
]} />
456453
<span className={releaseText}>{releaseVersion}</span>
457454
</div>
458455
);

packages/dev/s2-docs/src/ComponentCardClient.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {Card, CardPreview, CardProps, Content, Text} from '@react-spectrum/s2';
33
import {ReactNode, useEffect, useRef} from 'react';
44
import {registerSpectrumLink} from './prefetch';
5+
import {style} from '@react-spectrum/s2/style' with {type: 'macro'};
56

67
interface ComponentCardProps extends Omit<CardProps, 'children'> {
78
preview: ReactNode,
@@ -30,7 +31,7 @@ export function ComponentCardClient(props: ComponentCardProps) {
3031
<CardPreview>
3132
{preview}
3233
</CardPreview>
33-
<Content>
34+
<Content styles={style({alignContent: 'start'})}>
3435
<Text slot="title">{name}</Text>
3536
{description && <Text slot="description">{description}</Text>}
3637
</Content>

packages/dev/s2-docs/src/ExampleList.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,12 @@ export function ExampleImage({name, itemProp}: {name: string, itemProp?: string}
9999
let [light, dark] = img;
100100
return (
101101
<Image
102-
src={light}
102+
src={[
103+
{srcSet: light, colorScheme: 'light'},
104+
{srcSet: dark, colorScheme: 'dark'}
105+
]}
103106
alt=""
104107
itemProp={itemProp}
105-
styles={image}>
106-
<source srcSet={light} media="(prefers-color-scheme: light)" />
107-
<source srcSet={dark} media="(prefers-color-scheme: dark)" />
108-
</Image>
108+
styles={image} />
109109
);
110110
}

packages/dev/s2-docs/src/VisualExampleClient.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,9 @@ export function Control({name}: {name: string}) {
444444
if (name === 'placement' && control.value.elements.length === 22) {
445445
return <PlacementControl control={control} value={value} onChange={onChange} />;
446446
}
447+
if (name === 'src') {
448+
return <StringControl control={control} value={value} onChange={onChange} />;
449+
}
447450
return <UnionControl control={control} value={value} onChange={onChange} />;
448451
case 'number':
449452
return <NumberControl control={control} value={value} onChange={onChange} />;

0 commit comments

Comments
 (0)