Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/@react-aria/tooltip/src/useTooltipTrigger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function useTooltipTrigger(props: TooltipTriggerProps, state: TooltipTrig
let {
isDisabled,
trigger,
closeOnPress = true
shouldCloseOnPress = true
} = props;

let tooltipId = useId();
Expand Down Expand Up @@ -103,8 +103,8 @@ export function useTooltipTrigger(props: TooltipTriggerProps, state: TooltipTrig
};

let onPressStart = () => {
// if closeOnPress is false, we should not close the tooltip
if (!closeOnPress) {
// if shouldCloseOnPress is false, we should not close the tooltip
if (!shouldCloseOnPress) {
return;
}
// no matter how the trigger is pressed, we should close the tooltip
Expand Down
106 changes: 82 additions & 24 deletions packages/@react-spectrum/s2/src/Image.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {ColorSchemeContext} from './Provider';
import {ContextValue, SlotProps} from 'react-aria-components';
import {createContext, ForwardedRef, forwardRef, HTMLAttributeReferrerPolicy, JSX, ReactNode, useCallback, useContext, useMemo, useReducer, useRef, version} from 'react';
import {DefaultImageGroup, ImageGroup} from './ImageCoordinator';
Expand All @@ -9,9 +10,46 @@ import {UnsafeStyles} from './style-utils';
import {useLayoutEffect} from '@react-aria/utils';
import {useSpectrumContextProps} from './useSpectrumContextProps';

export interface ImageSource {
/**
* A comma-separated list of image URLs and descriptors.
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#srcset).
*/
srcSet?: string | undefined,
/**
* The color scheme for this image source. Unlike `media`, this respects the `Provider` color scheme setting.
*/
colorScheme?: 'light' | 'dark',
/**
* A media query describing when the source should render.
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#media).
*/
media?: string | undefined,
/**
* A list of source sizes that describe the final rendered width of the image.
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#sizes).
*/
sizes?: string | undefined,
/**
* The mime type of the image.
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#type).
*/
type?: string | undefined,
/**
* The intrinsic width of the image.
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#width).
*/
width?: number,
/**
* The intrinsic height of the image.
* [See MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/source#height).
*/
height?: number
}

export interface ImageProps extends UnsafeStyles, SlotProps {
/** The URL of the image. */
src?: string,
/** The URL of the image or a list of conditional sources. */
src?: string | ImageSource[],
// TODO
// srcSet?: string,
// sizes?: string,
Expand Down Expand Up @@ -61,10 +99,6 @@ export interface ImageProps extends UnsafeStyles, SlotProps {
* If not provided, the default image group is used.
*/
group?: ImageGroup,
/**
* Child `<source>` elements defining alternate versions of an image for different display/device scenarios.
*/
children?: ReactNode,
/**
* Associates the image with a microdata object.
* See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/itemprop).
Expand Down Expand Up @@ -159,7 +193,7 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
[props, domRef] = useSpectrumContextProps(props, domRef, ImageContext);

let {
src = '',
src: srcProp = '',
styles,
UNSAFE_className = '',
UNSAFE_style,
Expand All @@ -177,16 +211,17 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
slot,
width,
height,
children,
itemProp
} = props;
let hidden = (props as ImageContextValue).hidden;
let colorScheme = useContext(ColorSchemeContext);
let cacheKey = useMemo(() => typeof srcProp === 'object' ? JSON.stringify(srcProp) : srcProp, [srcProp]);

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

if (src !== lastSrc && !hidden) {
dispatch({type: 'update', src});
if (cacheKey !== lastCacheKey && !hidden) {
dispatch({type: 'update', src: cacheKey});
}

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

register(src);
register(cacheKey);
return () => {
unregister(src);
unregister(cacheKey);
};
}, [hidden, register, unregister, src]);
}, [hidden, register, unregister, cacheKey]);

let onLoad = useCallback(() => {
load(src);
load(cacheKey);
dispatch({type: 'loaded'});
}, [load, src]);
}, [load, cacheKey]);

let onError = useCallback(() => {
dispatch({type: 'error'});
unregister(src);
}, [unregister, src]);
unregister(cacheKey);
}, [unregister, cacheKey]);

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

// In React act environments, run immediately.
// @ts-ignore
let isTestEnv = typeof IS_REACT_ACT_ENVIRONMENT === 'boolean' ? IS_REACT_ACT_ENVIRONMENT : typeof jest !== 'undefined';
let runTask = isTestEnv ? fn => fn() : queueMicrotask;

// If the image is already loaded, update state immediately instead of waiting for onLoad.
let img = imgRef.current;
if (state === 'loading' && img?.complete) {
if (img.naturalWidth === 0 && img.naturalHeight === 0) {
// Queue a microtask so we don't hit React's update limit.
// TODO: is this necessary?
queueMicrotask(onError);
runTask(onError);
} else {
queueMicrotask(onLoad);
runTask(onLoad);
}
}

Expand All @@ -253,7 +293,7 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
let img = (
<img
{...getFetchPriorityProp(fetchPriority)}
src={src || undefined}
src={typeof srcProp === 'string' && srcProp ? srcProp : undefined}
alt={alt}
crossOrigin={crossOrigin}
decoding={decoding}
Expand All @@ -268,10 +308,28 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
className={imgStyles({isRevealed, isTransitioning})} />
);

if (children) {
if (Array.isArray(srcProp)) {
img = (
<picture>
{children}
{srcProp.map((source, i) => {
let {colorScheme: sourceColorScheme, ...sourceProps} = source;
if (sourceColorScheme) {
if (!colorScheme || colorScheme === 'light dark') {
return (
<source
key={i}
{...sourceProps}
media={`${source.media ? `${source.media} and ` : ''}(prefers-color-scheme: ${sourceColorScheme})`} />
);
}

return sourceColorScheme === colorScheme
? <source key={i} {...sourceProps} />
: null;
} else {
return <source key={i} {...sourceProps} />;
}
})}
{img}
</picture>
);
Expand All @@ -287,7 +345,7 @@ export const Image = forwardRef(function Image(props: ImageProps, domRef: Forwar
{!errorState && img}
</div>
);
}, [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]);
}, [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]);
});

function getFetchPriorityProp(fetchPriority?: 'high' | 'low' | 'auto'): Record<string, string | undefined> {
Expand Down
2 changes: 1 addition & 1 deletion packages/@react-spectrum/s2/src/Skeleton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function useLoadingAnimation(isAnimating: boolean): (element: HTMLElement
let animationRef = useRef<Animation | null>(null);
let reduceMotion = useMediaQuery('(prefers-reduced-motion: reduce)');
return useCallback((element: HTMLElement | null) => {
if (isAnimating && !animationRef.current && element && !reduceMotion) {
if (isAnimating && !animationRef.current && element && !reduceMotion && typeof element.animate === 'function') {
// Use web animation API instead of CSS animations so that we can
// synchronize it between all loading elements on the page (via startTime).
animationRef.current = element.animate(
Expand Down
65 changes: 65 additions & 0 deletions packages/@react-spectrum/s2/test/Image.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/*
* Copyright 2025 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {Image, Provider} from '../src';
import {render} from '@react-spectrum/test-utils-internal';

describe('Image', () => {
it('should support conditional sources', async () => {
let {getByRole} = render(
<Image
alt="test"
src={[
{srcSet: 'foo.png', type: 'image/png', colorScheme: 'light'},
{srcSet: 'bar.png', colorScheme: 'dark', media: '(width >= 500px)'},
{srcSet: 'default.png'}
]} />
);

let img = getByRole('img');
let picture = img.parentElement!;
expect(picture.tagName).toBe('PICTURE');
let sources = picture.querySelectorAll('source');

expect(sources).toHaveLength(3);
expect(sources[0]).toHaveAttribute('srcset', 'foo.png');
expect(sources[0]).toHaveAttribute('type', 'image/png');
expect(sources[0]).toHaveAttribute('media', '(prefers-color-scheme: light)');
expect(sources[1]).toHaveAttribute('srcset', 'bar.png');
expect(sources[1]).toHaveAttribute('media', '(width >= 500px) and (prefers-color-scheme: dark)');
expect(sources[2]).toHaveAttribute('srcset', 'default.png');
});

it('should support conditional sources with Provider colorScheme override', async () => {
let {getByRole} = render(
<Provider colorScheme="dark">
<Image
alt="test"
src={[
{srcSet: 'foo.png', type: 'image/png', colorScheme: 'light'},
{srcSet: 'bar.png', colorScheme: 'dark', media: '(width >= 500px)'},
{srcSet: 'default.png'}
]} />
</Provider>
);

let img = getByRole('img');
let picture = img.parentElement!;
expect(picture.tagName).toBe('PICTURE');
let sources = picture.querySelectorAll('source');

expect(sources).toHaveLength(2);
expect(sources[0]).toHaveAttribute('srcset', 'bar.png');
expect(sources[0]).toHaveAttribute('media', '(width >= 500px)');
expect(sources[1]).toHaveAttribute('srcset', 'default.png');
});
});
6 changes: 3 additions & 3 deletions packages/@react-spectrum/tooltip/src/TooltipTrigger.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {useTooltipTriggerState} from '@react-stately/tooltip';

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

function TooltipTrigger(props: SpectrumTooltipTriggerProps) {
let {
Expand All @@ -31,7 +31,7 @@ function TooltipTrigger(props: SpectrumTooltipTriggerProps) {
isDisabled,
offset = DEFAULT_OFFSET,
trigger: triggerAction,
closeOnPress = DEFAULT_CLOSE_ON_PRESS
shouldCloseOnPress = DEFAULT_SHOULD_CLOSE_ON_PRESS
} = props;

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

let [borderRadius, setBorderRadius] = useState(0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ const argTypes = {
children: {
control: {disable: true}
},
closeOnPress: {
shouldCloseOnPress: {
control: 'boolean'
}
};
Expand Down Expand Up @@ -117,7 +117,7 @@ export default {
<Tooltip>Change Name</Tooltip>
],
onOpenChange: action('openChange'),
closeOnPress: true
shouldCloseOnPress: true
},
argTypes: argTypes
} as Meta<typeof TooltipTrigger>;
Expand Down
8 changes: 4 additions & 4 deletions packages/@react-spectrum/tooltip/test/TooltipTrigger.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -330,10 +330,10 @@ describe('TooltipTrigger', function () {
expect(queryByRole('tooltip')).toBeNull();
});

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

it('does not close if the trigger is clicked with the keyboard when closeOnPress is false', async () => {
it('does not close if the trigger is clicked with the keyboard when shouldCloseOnPress is false', async () => {
let {getByRole, getByLabelText} = render(
<Provider theme={theme}>
<TooltipTrigger onOpenChange={onOpenChange} delay={0} closeOnPress={false}>
<TooltipTrigger onOpenChange={onOpenChange} delay={0} shouldCloseOnPress={false}>
<ActionButton aria-label="trigger" />
<Tooltip>Helpful information.</Tooltip>
</TooltipTrigger>
Expand Down
7 changes: 5 additions & 2 deletions packages/@react-stately/utils/src/useControlledState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import React, {SetStateAction, useCallback, useEffect, useRef, useState} from 'react';
import React, {SetStateAction, useCallback, useEffect, useReducer, useRef, useState} from 'react';

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

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

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

// Always trigger a re-render, even when controlled, so that the layout effect above runs to reset the value.
forceUpdate();

// Trigger onChange. Note that if setState is called multiple times in a single event,
// onChange will be called for each one instead of only once.
onChange?.(newValue, ...args);
Expand Down
Loading
Loading