diff --git a/packages/react-native/Libraries/Image/Image.ios.js b/packages/react-native/Libraries/Image/Image.ios.js index 3fc14f2bcf14..da0735c8fa59 100644 --- a/packages/react-native/Libraries/Image/Image.ios.js +++ b/packages/react-native/Libraries/Image/Image.ios.js @@ -29,6 +29,7 @@ import ImageViewNativeComponent from './ImageViewNativeComponent'; import NativeImageLoaderIOS from './NativeImageLoaderIOS'; import resolveAssetSource from './resolveAssetSource'; import * as React from 'react'; +import {use} from 'react'; export type ImageInstance = HostInstance; @@ -102,6 +103,12 @@ async function queryCache( return NativeImageLoaderIOS.queryCache(urls); } +const EMPTY_IMAGE_SOURCE = { + uri: undefined, + width: undefined, + height: undefined, +}; + /** * A React component for displaying different types of images, * including network images, static resources, temporary local images, and @@ -111,90 +118,127 @@ async function queryCache( */ let BaseImage: AbstractImageIOS = ({ ref: forwardedRef, - ...props + accessible, + accessibilityLabel, + accessibilityState, + 'aria-busy': ariaBusy, + 'aria-checked': ariaChecked, + 'aria-disabled': ariaDisabled, + 'aria-expanded': ariaExpanded, + 'aria-hidden': ariaHidden, + 'aria-label': ariaLabel, + 'aria-selected': ariaSelected, + alt, + children, + crossOrigin, + height, + referrerPolicy, + resizeMode, + source, + src, + srcSet, + style, + tintColor, + width, + ...restProps }: { ref?: React.RefSetter, ...ImageProps, }) => { - const source = getImageSourcesFromImageProps(props) || { - uri: undefined, - width: undefined, - height: undefined, - }; + const resolvedSource = + getImageSourcesFromImageProps({ + crossOrigin, + height, + referrerPolicy, + source, + src, + srcSet, + width, + }) || EMPTY_IMAGE_SOURCE; - let style: ImageStyleProp; + let resolvedStyle: ImageStyleProp; let sources; - if (Array.isArray(source)) { - style = [styles.base, props.style]; - sources = source; + if (Array.isArray(resolvedSource)) { + resolvedStyle = [styles.base, style]; + sources = resolvedSource; } else { - const {uri} = source; + const {uri} = resolvedSource; if (uri === '') { console.warn('source.uri should not be an empty string'); } - const width = source.width ?? props.width; - const height = source.height ?? props.height; - style = [{width, height}, styles.base, props.style]; - sources = [source]; + resolvedStyle = [ + { + width: resolvedSource.width ?? width, + height: resolvedSource.height ?? height, + }, + styles.base, + style, + ]; + sources = [resolvedSource]; } - const flattenedStyle = flattenStyle(style); - const objectFit = convertObjectFitToResizeMode(flattenedStyle?.objectFit); - const resizeMode = - objectFit || props.resizeMode || flattenedStyle?.resizeMode || 'cover'; - const tintColor = props.tintColor ?? flattenedStyle?.tintColor; - - if (props.children != null) { + if (children != null) { throw new Error( 'The component cannot contain children. If you want to render content on top of the image, consider using the component or absolute positioning.', ); } - const { - 'aria-busy': ariaBusy, - 'aria-checked': ariaChecked, - 'aria-disabled': ariaDisabled, - 'aria-expanded': ariaExpanded, - 'aria-selected': ariaSelected, - 'aria-hidden': ariaHidden, - src, - ...restProps - } = props; - - const _accessibilityState = { - busy: ariaBusy ?? props.accessibilityState?.busy, - checked: ariaChecked ?? props.accessibilityState?.checked, - disabled: ariaDisabled ?? props.accessibilityState?.disabled, - expanded: ariaExpanded ?? props.accessibilityState?.expanded, - selected: ariaSelected ?? props.accessibilityState?.selected, + + const resolvedProps = restProps as { + ...React.PropsOf, }; - // In order for `aria-hidden` to work on iOS we must set `accessible` to false (`accessibilityElementsHidden` is not enough). - const accessible = - ariaHidden !== true && (props.alt !== undefined ? true : props.accessible); - const accessibilityLabel = props['aria-label'] ?? props.accessibilityLabel; + resolvedProps.style = resolvedStyle; + resolvedProps.source = sources; + + const flattenedStyle = flattenStyle(resolvedStyle); + const objectFit = convertObjectFitToResizeMode(flattenedStyle?.objectFit); + resolvedProps.resizeMode = + objectFit || resizeMode || flattenedStyle?.resizeMode || 'cover'; + resolvedProps.tintColor = tintColor ?? flattenedStyle?.tintColor; + + if (ariaLabel != null) { + resolvedProps.accessibilityLabel = ariaLabel; + } else if (accessibilityLabel != null) { + resolvedProps.accessibilityLabel = accessibilityLabel; + } else if (alt != null) { + resolvedProps.accessibilityLabel = alt; + } + + if (ariaHidden === true) { + // In order for `aria-hidden` to work on iOS we must set `accessible` to + // false (`accessibilityElementsHidden` is not enough). + resolvedProps.accessible = false; + } else if (alt != null) { + resolvedProps.accessible = true; + } else if (accessible != null) { + resolvedProps.accessible = accessible; + } + + if ( + accessibilityState != null || + ariaBusy != null || + ariaChecked != null || + ariaDisabled != null || + ariaExpanded != null || + ariaSelected != null + ) { + resolvedProps.accessibilityState = { + busy: ariaBusy ?? accessibilityState?.busy, + checked: ariaChecked ?? accessibilityState?.checked, + disabled: ariaDisabled ?? accessibilityState?.disabled, + expanded: ariaExpanded ?? accessibilityState?.expanded, + selected: ariaSelected ?? accessibilityState?.selected, + }; + } const actualRef = useWrapRefWithImageAttachedCallbacks(forwardedRef); - return ( - - {analyticTag => { - return ( - - ); - }} - - ); + const analyticTag = use(ImageAnalyticsTagContext); + if (analyticTag != null) { + resolvedProps.internal_analyticTag = analyticTag; + } + + return ; }; const imageComponentDecorator = unstable_getImageComponentDecorator(); diff --git a/packages/react-native/Libraries/Image/__tests__/Image-itest.js b/packages/react-native/Libraries/Image/__tests__/Image-itest.js index 33ac94c94566..a1f3140f850b 100644 --- a/packages/react-native/Libraries/Image/__tests__/Image-itest.js +++ b/packages/react-native/Libraries/Image/__tests__/Image-itest.js @@ -648,6 +648,88 @@ describe('', () => { }); }); + describe('accessibilityState', () => { + function getAccessibilityState(element: React.MixedElement) { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(element); + }); + + return root + .getRenderedOutput({props: ['accessibilityState']}) + .toJSONObject().props.accessibilityState; + } + + it('is not set when no state props are provided', () => { + const root = Fantom.createRoot(); + + Fantom.runTask(() => { + root.render(); + }); + + expect( + root.getRenderedOutput({props: ['accessibilityState']}).toJSX(), + ).toEqual(); + }); + + it('maps \'aria-busy\' to "busy"', () => { + expect(getAccessibilityState()).toContain( + 'busy:true', + ); + }); + + it('maps \'aria-disabled\' to "disabled"', () => { + expect(getAccessibilityState()).toContain( + 'disabled:true', + ); + }); + + it('maps \'aria-expanded\' to "expanded"', () => { + expect(getAccessibilityState()).toContain( + 'expanded:true', + ); + }); + + it('maps \'aria-selected\' to "selected"', () => { + expect(getAccessibilityState()).toContain( + 'selected:true', + ); + }); + + describe('maps \'aria-checked\' to "checked"', () => { + it('when set to true', () => { + expect( + getAccessibilityState(), + ).toContain('checked:Checked'); + }); + + it('when set to false', () => { + expect( + getAccessibilityState(), + ).toContain('checked:Unchecked'); + }); + + it("when set to 'mixed'", () => { + expect( + getAccessibilityState(), + ).toContain('checked:Mixed'); + }); + }); + + it('gives `aria-*` precedence over the matching field', () => { + const accessibilityState = getAccessibilityState( + , + ); + + expect(accessibilityState).toContain('busy:true'); + expect(accessibilityState).toContain('disabled:true'); + }); + }); + component TestComponent(testID?: ?string, ...props: AccessibilityProps) { return ; }