Skip to content

Commit 7fc2907

Browse files
committed
feat(a11y): whatwg-compliant accessibility for images
Images now hold the `accessibilityRole` and `accessibilityLabel` regardless of the internal state (loading, success, error). Also note that an image with `alt=""` or no `alt` attribute will not be accessible, as mandated by WHATWG HTML standard.
1 parent 4bb2fcf commit 7fc2907

File tree

10 files changed

+99
-64
lines changed

10 files changed

+99
-64
lines changed

packages/render-html/src/TBlockRenderer.tsx

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,21 @@ import { TDefaultRenderer } from './shared-types';
66
import { TNodeSubRendererProps } from './internal-types';
77
import GenericPressable from './GenericPressable';
88
import useAssembledCommonProps from './hooks/useAssembledCommonProps';
9+
import getNativePropsForTNode from './helpers/getNativePropsForTNode';
910

1011
export const TDefaultBlockRenderer: TDefaultRenderer<TBlock> = ({
11-
tnode,
1212
children: overridingChildren,
13-
style,
1413
onPress,
15-
viewProps,
16-
nativeProps,
17-
propsForChildren
14+
...props
1815
}) => {
1916
const TNodeChildrenRenderer = useTNodeChildrenRenderer();
2017
const children = overridingChildren ?? (
21-
<TNodeChildrenRenderer tnode={tnode} propsForChildren={propsForChildren} />
18+
<TNodeChildrenRenderer
19+
tnode={props.tnode}
20+
propsForChildren={props.propsForChildren}
21+
/>
2222
);
23-
const commonProps = {
24-
...tnode.getReactNativeProps()?.view,
25-
...nativeProps,
26-
...viewProps,
27-
style: [style, nativeProps?.style, viewProps.style],
28-
testID: tnode.tagName
29-
};
23+
const commonProps = getNativePropsForTNode(props);
3024
if (typeof onPress === 'function') {
3125
return React.createElement(
3226
GenericPressable,

packages/render-html/src/__tests__/component.render-html.test.tsx

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ describe('RenderHTML', () => {
6363
);
6464
await waitFor(() => UNSAFE_getByType(ULElement));
6565
});
66-
it('should update ImgTag contentWidth when contentWidth prop changes', () => {
66+
it('should update <img> contentWidth when contentWidth prop changes', () => {
6767
const contentWidth = 300;
6868
const nextContentWidth = 200;
6969
const { UNSAFE_getByType, update } = render(
@@ -85,6 +85,40 @@ describe('RenderHTML', () => {
8585
nextContentWidth
8686
);
8787
});
88+
it('should provide accessibility properties to <img> renderer', () => {
89+
const { getByA11yRole } = render(
90+
<RenderHTML
91+
source={{
92+
html: '<img alt="An image" src="https://img.com/1" />'
93+
}}
94+
debug={false}
95+
contentWidth={200}
96+
/>
97+
);
98+
const imgProps = getByA11yRole('image').props;
99+
expect(imgProps.accessibilityRole).toBe('image');
100+
expect(imgProps.accessibilityLabel).toBe('An image');
101+
});
102+
it('should merge `viewStyle` to <img> renderer', () => {
103+
const { getByA11yRole } = render(
104+
<RenderHTML
105+
source={{
106+
html: '<img alt="An image" src="https://img.com/1" />'
107+
}}
108+
debug={false}
109+
defaultViewProps={{
110+
style: {
111+
backgroundColor: 'red'
112+
}
113+
}}
114+
contentWidth={200}
115+
/>
116+
);
117+
const imgProps = getByA11yRole('image').props;
118+
expect(StyleSheet.flatten(imgProps.style)).toMatchObject({
119+
backgroundColor: 'red'
120+
});
121+
});
88122
it('should use internal text renderer for <wbr> tags', async () => {
89123
const { findByText } = render(
90124
<RenderHTML

packages/render-html/src/elements/IMGElement.tsx

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,13 +23,9 @@ function identity(arg: any) {
2323
* {@link IMGElementContentSuccess}, {@link IMGElementContentLoading}
2424
* and {@link IMGElementContentError} for customization.
2525
*/
26-
function IMGElement({
27-
onPress,
28-
testID,
29-
...props
30-
}: IMGElementProps): ReactElement {
26+
function IMGElement(props: IMGElementProps): ReactElement {
3127
const state = useIMGElementState(props);
32-
let content: ReactNode = false;
28+
let content: ReactNode;
3329
if (state.type === 'success') {
3430
content = React.createElement(IMGElementContentSuccess, state);
3531
} else if (state.type === 'loading') {
@@ -39,8 +35,9 @@ function IMGElement({
3935
}
4036
return (
4137
<IMGElementContainer
42-
testID={testID}
43-
onPress={onPress}
38+
testID={props.testID}
39+
{...props.containerProps}
40+
onPress={props.onPress}
4441
style={state.containerStyle}>
4542
{content}
4643
</IMGElementContainer>
@@ -66,7 +63,8 @@ const propTypes: Record<keyof IMGElementProps, any> = {
6663
onPress: PropTypes.func,
6764
testID: PropTypes.string,
6865
objectFit: PropTypes.string,
69-
cachedNaturalDimensions: imgDimensionsType
66+
cachedNaturalDimensions: imgDimensionsType,
67+
containerProps: PropTypes.object
7068
};
7169

7270
/**

packages/render-html/src/elements/IMGElementContainer.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, {
44
ReactElement,
55
useMemo
66
} from 'react';
7-
import { View, StyleSheet, ViewStyle } from 'react-native';
7+
import { View, StyleSheet, ViewStyle, ViewProps } from 'react-native';
88
import GenericPressable from '../GenericPressable';
99
import { IMGElementProps } from './img-types';
1010

@@ -23,9 +23,11 @@ export default function IMGElementContainer({
2323
style,
2424
onPress,
2525
testID,
26-
children
26+
children,
27+
...otherProps
2728
}: PropsWithChildren<
28-
Pick<IMGElementProps, 'onPress' | 'testID'> & { style: ViewStyle }
29+
Pick<IMGElementProps, 'onPress' | 'testID'> &
30+
Omit<ViewProps, 'style'> & { style: ViewStyle }
2931
>): ReactElement {
3032
const containerStyle = useMemo(() => {
3133
const { width, height, ...remainingStyle } = style;
@@ -35,7 +37,7 @@ export default function IMGElementContainer({
3537
typeof onPress === 'function' ? GenericPressable : View;
3638
return React.createElement(
3739
Container,
38-
{ style: containerStyle, onPress, testID },
40+
{ ...otherProps, style: containerStyle, onPress, testID },
3941
children
4042
);
4143
}

packages/render-html/src/elements/IMGElementContentLoading.tsx

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,10 @@ import { IMGElementStateLoading } from './img-types';
77
*/
88
export default function IMGElementContentLoading({
99
dimensions,
10-
alt,
1110
children
1211
}: PropsWithChildren<IMGElementStateLoading>): ReactElement {
1312
return (
14-
<View
15-
style={dimensions}
16-
accessibilityRole="image"
17-
accessibilityLabel={alt}
18-
testID="image-loading">
13+
<View style={dimensions} testID="image-loading">
1914
{children}
2015
</View>
2116
);

packages/render-html/src/elements/IMGElementContentSuccess.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@ const defaultImageStyle: ImageStyle = { resizeMode: 'cover' };
1313
* Default success "image" view for the {@link IMGElement} component.
1414
*/
1515
export default function IMGElementContentSuccess({
16-
alt,
1716
source,
1817
imageStyle,
1918
dimensions,
@@ -26,8 +25,6 @@ export default function IMGElementContentSuccess({
2625
);
2726
return (
2827
<Image
29-
accessibilityRole="image"
30-
accessibilityLabel={alt}
3128
source={source}
3229
onError={onImageError}
3330
style={[defaultImageStyle, dimensions, imageStyle]}

packages/render-html/src/elements/img-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
ImageURISource,
44
PressableProps,
55
StyleProp,
6+
ViewProps,
67
ViewStyle
78
} from 'react-native';
89
import { ImageDimensions } from '../shared-types';
@@ -76,6 +77,7 @@ export interface UseIMGElementStateProps {
7677
* Props for the {@link IMGElement} component.
7778
*/
7879
export interface IMGElementProps extends UseIMGElementStateProps {
80+
containerProps?: Omit<ViewProps, 'style'>;
7981
/**
8082
* A callback triggered on press.
8183
*/
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { TBlock, TPhrasing, TText } from '@native-html/transient-render-engine';
2+
import { TDefaultRendererProps } from '../shared-types';
3+
4+
/**
5+
* Extract React Native props for a given {@link TNode}. Native props target
6+
* either `Text` or `View` elements, with an optional `onPress` prop for
7+
* interactive elements.
8+
*/
9+
export default function getNativePropsForTNode(
10+
props: TDefaultRendererProps<TPhrasing | TText | TBlock>
11+
) {
12+
const { tnode, style, type, nativeProps, onPress } = props;
13+
const switchProp = type === 'block' ? props.viewProps : props.textProps;
14+
return {
15+
...tnode.getReactNativeProps()?.[type === 'block' ? 'view' : 'text'],
16+
...nativeProps,
17+
...switchProp,
18+
onPress,
19+
style: [style, nativeProps?.style, switchProp.style],
20+
testID: tnode.tagName || undefined
21+
};
22+
}

packages/render-html/src/renderTextualContent.tsx

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,30 +2,13 @@ import React, { ReactNode } from 'react';
22
import { Text } from 'react-native';
33
import { TPhrasing, TText } from '@native-html/transient-render-engine';
44
import { TDefaultRendererProps } from './shared-types';
5+
import getNativePropsForTNode from './helpers/getNativePropsForTNode';
56

67
const renderTextualContent = (
7-
{
8-
tnode,
9-
style,
10-
textProps,
11-
nativeProps,
12-
onPress
13-
}: TDefaultRendererProps<TPhrasing | TText>,
8+
props: TDefaultRendererProps<TPhrasing | TText>,
149
children: ReactNode
1510
) => {
16-
const resolvedStyles = [style, nativeProps?.style, textProps.style];
17-
return React.createElement(
18-
Text,
19-
{
20-
...tnode.getReactNativeProps()?.text,
21-
...nativeProps,
22-
...textProps,
23-
onPress,
24-
style: resolvedStyles,
25-
testID: tnode.tagName || undefined
26-
},
27-
children
28-
);
11+
return React.createElement(Text, getNativePropsForTNode(props), children);
2912
};
3013

3114
export default renderTextualContent;

packages/render-html/src/renderers/IMGRenderer.tsx

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import React from 'react';
1+
import React, { useMemo } from 'react';
22
import { TBlock } from '@native-html/transient-render-engine';
33
import IMGElement, { IMGElementProps } from '../elements/IMGElement';
44
import { InternalBlockRenderer } from '../render/render-types';
55
import { useComputeMaxWidthForTag } from '../context/SharedPropsProvider';
6-
import { ImageStyle } from 'react-native';
6+
import { ImageStyle, StyleSheet } from 'react-native';
77
import { InternalRendererProps } from '../shared-types';
88
import useNormalizedUrl from '../hooks/useNormalizedUrl';
99
import { useRendererProps } from '../context/RenderersPropsProvider';
1010
import useContentWidth from '../hooks/useContentWidth';
11+
import getNativePropsForTNode from '../helpers/getNativePropsForTNode';
1112

1213
/**
1314
* A hook to produce props consumable by {@link IMGElement} component
@@ -16,23 +17,30 @@ import useContentWidth from '../hooks/useContentWidth';
1617
export function useIMGElementProps(
1718
props: InternalRendererProps<TBlock>
1819
): IMGElementProps {
19-
const { style, tnode, onPress } = props;
20+
const { tnode } = props;
21+
2022
const contentWidth = useContentWidth();
2123
const { initialDimensions, enableExperimentalPercentWidth } =
2224
useRendererProps('img');
2325
const computeImagesMaxWidth = useComputeMaxWidthForTag('img');
2426
const src = tnode.attributes.src || '';
27+
const source = { uri: useNormalizedUrl(src) };
28+
const { style: rawStyle, ...containerProps } = getNativePropsForTNode(props);
29+
const style = useMemo<ImageStyle>(
30+
() => (rawStyle ? (StyleSheet.flatten(rawStyle) as ImageStyle) : {}),
31+
[rawStyle]
32+
);
2533
return {
2634
contentWidth,
27-
computeMaxWidth: computeImagesMaxWidth,
35+
containerProps,
2836
enableExperimentalPercentWidth,
2937
initialDimensions,
30-
onPress,
31-
alt: tnode.attributes.alt,
38+
source,
39+
style,
3240
testID: 'img',
41+
computeMaxWidth: computeImagesMaxWidth,
42+
alt: tnode.attributes.alt,
3343
altColor: tnode.styles.nativeTextFlow.color as string,
34-
source: { uri: useNormalizedUrl(src) },
35-
style: style as ImageStyle,
3644
width: tnode.attributes.width,
3745
height: tnode.attributes.height,
3846
objectFit: tnode.styles.webBlockRet.objectFit

0 commit comments

Comments
 (0)