Skip to content

Commit 1ec3eba

Browse files
committed
Merge branch 'main' of github.com:adobe/react-spectrum into homepage
2 parents bdcb384 + 3d7bc7f commit 1ec3eba

File tree

23 files changed

+238
-624
lines changed

23 files changed

+238
-624
lines changed

packages/@react-aria/tooltip/src/useTooltipTrigger.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export function useTooltipTrigger(props: TooltipTriggerProps, state: TooltipTrig
3737
let {
3838
isDisabled,
3939
trigger,
40-
closeOnPress = true
40+
shouldCloseOnPress = true
4141
} = props;
4242

4343
let tooltipId = useId();
@@ -103,8 +103,8 @@ export function useTooltipTrigger(props: TooltipTriggerProps, state: TooltipTrig
103103
};
104104

105105
let onPressStart = () => {
106-
// if closeOnPress is false, we should not close the tooltip
107-
if (!closeOnPress) {
106+
// if shouldCloseOnPress is false, we should not close the tooltip
107+
if (!shouldCloseOnPress) {
108108
return;
109109
}
110110
// no matter how the trigger is pressed, we should close the tooltip

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/@react-spectrum/tooltip/src/TooltipTrigger.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {useTooltipTriggerState} from '@react-stately/tooltip';
2222

2323
const DEFAULT_OFFSET = -1; // Offset needed to reach 4px/5px (med/large) distance between tooltip and trigger button
2424
const DEFAULT_CROSS_OFFSET = 0;
25-
const DEFAULT_CLOSE_ON_PRESS = true; // Whether the tooltip should close when the trigger is pressed
25+
const DEFAULT_SHOULD_CLOSE_ON_PRESS = true; // Whether the tooltip should close when the trigger is pressed
2626

2727
function TooltipTrigger(props: SpectrumTooltipTriggerProps) {
2828
let {
@@ -31,7 +31,7 @@ function TooltipTrigger(props: SpectrumTooltipTriggerProps) {
3131
isDisabled,
3232
offset = DEFAULT_OFFSET,
3333
trigger: triggerAction,
34-
closeOnPress = DEFAULT_CLOSE_ON_PRESS
34+
shouldCloseOnPress = DEFAULT_SHOULD_CLOSE_ON_PRESS
3535
} = props;
3636

3737
let [trigger, tooltip] = React.Children.toArray(children) as [ReactElement, ReactElement];
@@ -43,7 +43,7 @@ function TooltipTrigger(props: SpectrumTooltipTriggerProps) {
4343
let {triggerProps, tooltipProps} = useTooltipTrigger({
4444
isDisabled,
4545
trigger: triggerAction,
46-
closeOnPress
46+
shouldCloseOnPress
4747
}, state, tooltipTriggerRef);
4848

4949
let [borderRadius, setBorderRadius] = useState(0);

packages/@react-spectrum/tooltip/stories/TooltipTrigger.stories.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ const argTypes = {
7373
children: {
7474
control: {disable: true}
7575
},
76-
closeOnPress: {
76+
shouldCloseOnPress: {
7777
control: 'boolean'
7878
}
7979
};
@@ -117,7 +117,7 @@ export default {
117117
<Tooltip>Change Name</Tooltip>
118118
],
119119
onOpenChange: action('openChange'),
120-
closeOnPress: true
120+
shouldCloseOnPress: true
121121
},
122122
argTypes: argTypes
123123
} as Meta<typeof TooltipTrigger>;

packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -330,10 +330,10 @@ describe('TooltipTrigger', function () {
330330
expect(queryByRole('tooltip')).toBeNull();
331331
});
332332

333-
it('does not close if the trigger is clicked when closeOnPress is false', async () => {
333+
it('does not close if the trigger is clicked when shouldCloseOnPress is false', async () => {
334334
let {getByRole, getByLabelText} = render(
335335
<Provider theme={theme}>
336-
<TooltipTrigger onOpenChange={onOpenChange} delay={0} closeOnPress={false}>
336+
<TooltipTrigger onOpenChange={onOpenChange} delay={0} shouldCloseOnPress={false}>
337337
<ActionButton aria-label="trigger" />
338338
<Tooltip>Helpful information.</Tooltip>
339339
</TooltipTrigger>
@@ -351,10 +351,10 @@ describe('TooltipTrigger', function () {
351351
expect(tooltip).toBeVisible();
352352
});
353353

354-
it('does not close if the trigger is clicked with the keyboard when closeOnPress is false', async () => {
354+
it('does not close if the trigger is clicked with the keyboard when shouldCloseOnPress is false', async () => {
355355
let {getByRole, getByLabelText} = render(
356356
<Provider theme={theme}>
357-
<TooltipTrigger onOpenChange={onOpenChange} delay={0} closeOnPress={false}>
357+
<TooltipTrigger onOpenChange={onOpenChange} delay={0} shouldCloseOnPress={false}>
358358
<ActionButton aria-label="trigger" />
359359
<Tooltip>Helpful information.</Tooltip>
360360
</TooltipTrigger>

packages/@react-stately/utils/src/useControlledState.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import React, {SetStateAction, useCallback, useEffect, useRef, useState} from 'react';
13+
import React, {SetStateAction, useCallback, useEffect, useReducer, useRef, useState} from 'react';
1414

1515
// Use the earliest effect possible to reset the ref below.
1616
const useEarlyEffect: typeof React.useLayoutEffect = typeof document !== 'undefined'
@@ -43,16 +43,19 @@ export function useControlledState<T, C = T>(value: T, defaultValue: T, onChange
4343
valueRef.current = currentValue;
4444
});
4545

46+
let [, forceUpdate] = useReducer(() => ({}), {});
4647
let setValue = useCallback((value: SetStateAction<T>, ...args: any[]) => {
4748
// @ts-ignore - TS doesn't know that T cannot be a function.
4849
let newValue = typeof value === 'function' ? value(valueRef.current) : value;
4950
if (!Object.is(valueRef.current, newValue)) {
5051
// Update the ref so that the next setState callback has the most recent value.
5152
valueRef.current = newValue;
5253

53-
// Always trigger a setState, even when controlled, so that the layout effect above runs to reset the value.
5454
setStateValue(newValue);
5555

56+
// Always trigger a re-render, even when controlled, so that the layout effect above runs to reset the value.
57+
forceUpdate();
58+
5659
// Trigger onChange. Note that if setState is called multiple times in a single event,
5760
// onChange will be called for each one instead of only once.
5861
onChange?.(newValue, ...args);

0 commit comments

Comments
 (0)