Skip to content

Commit

Permalink
feat(a11y): whatwg-compliant accessibility for images
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
jsamr committed Sep 5, 2021
1 parent 4bb2fcf commit 7fc2907
Show file tree
Hide file tree
Showing 10 changed files with 99 additions and 64 deletions.
20 changes: 7 additions & 13 deletions packages/render-html/src/TBlockRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,27 +6,21 @@ import { TDefaultRenderer } from './shared-types';
import { TNodeSubRendererProps } from './internal-types';
import GenericPressable from './GenericPressable';
import useAssembledCommonProps from './hooks/useAssembledCommonProps';
import getNativePropsForTNode from './helpers/getNativePropsForTNode';

export const TDefaultBlockRenderer: TDefaultRenderer<TBlock> = ({
tnode,
children: overridingChildren,
style,
onPress,
viewProps,
nativeProps,
propsForChildren
...props
}) => {
const TNodeChildrenRenderer = useTNodeChildrenRenderer();
const children = overridingChildren ?? (
<TNodeChildrenRenderer tnode={tnode} propsForChildren={propsForChildren} />
<TNodeChildrenRenderer
tnode={props.tnode}
propsForChildren={props.propsForChildren}
/>
);
const commonProps = {
...tnode.getReactNativeProps()?.view,
...nativeProps,
...viewProps,
style: [style, nativeProps?.style, viewProps.style],
testID: tnode.tagName
};
const commonProps = getNativePropsForTNode(props);
if (typeof onPress === 'function') {
return React.createElement(
GenericPressable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe('RenderHTML', () => {
);
await waitFor(() => UNSAFE_getByType(ULElement));
});
it('should update ImgTag contentWidth when contentWidth prop changes', () => {
it('should update <img> contentWidth when contentWidth prop changes', () => {
const contentWidth = 300;
const nextContentWidth = 200;
const { UNSAFE_getByType, update } = render(
Expand All @@ -85,6 +85,40 @@ describe('RenderHTML', () => {
nextContentWidth
);
});
it('should provide accessibility properties to <img> renderer', () => {
const { getByA11yRole } = render(
<RenderHTML
source={{
html: '<img alt="An image" src="https://img.com/1" />'
}}
debug={false}
contentWidth={200}
/>
);
const imgProps = getByA11yRole('image').props;
expect(imgProps.accessibilityRole).toBe('image');
expect(imgProps.accessibilityLabel).toBe('An image');
});
it('should merge `viewStyle` to <img> renderer', () => {
const { getByA11yRole } = render(
<RenderHTML
source={{
html: '<img alt="An image" src="https://img.com/1" />'
}}
debug={false}
defaultViewProps={{
style: {
backgroundColor: 'red'
}
}}
contentWidth={200}
/>
);
const imgProps = getByA11yRole('image').props;
expect(StyleSheet.flatten(imgProps.style)).toMatchObject({
backgroundColor: 'red'
});
});
it('should use internal text renderer for <wbr> tags', async () => {
const { findByText } = render(
<RenderHTML
Expand Down
16 changes: 7 additions & 9 deletions packages/render-html/src/elements/IMGElement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,9 @@ function identity(arg: any) {
* {@link IMGElementContentSuccess}, {@link IMGElementContentLoading}
* and {@link IMGElementContentError} for customization.
*/
function IMGElement({
onPress,
testID,
...props
}: IMGElementProps): ReactElement {
function IMGElement(props: IMGElementProps): ReactElement {
const state = useIMGElementState(props);
let content: ReactNode = false;
let content: ReactNode;
if (state.type === 'success') {
content = React.createElement(IMGElementContentSuccess, state);
} else if (state.type === 'loading') {
Expand All @@ -39,8 +35,9 @@ function IMGElement({
}
return (
<IMGElementContainer
testID={testID}
onPress={onPress}
testID={props.testID}
{...props.containerProps}
onPress={props.onPress}
style={state.containerStyle}>
{content}
</IMGElementContainer>
Expand All @@ -66,7 +63,8 @@ const propTypes: Record<keyof IMGElementProps, any> = {
onPress: PropTypes.func,
testID: PropTypes.string,
objectFit: PropTypes.string,
cachedNaturalDimensions: imgDimensionsType
cachedNaturalDimensions: imgDimensionsType,
containerProps: PropTypes.object
};

/**
Expand Down
10 changes: 6 additions & 4 deletions packages/render-html/src/elements/IMGElementContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import React, {
ReactElement,
useMemo
} from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native';
import { View, StyleSheet, ViewStyle, ViewProps } from 'react-native';
import GenericPressable from '../GenericPressable';
import { IMGElementProps } from './img-types';

Expand All @@ -23,9 +23,11 @@ export default function IMGElementContainer({
style,
onPress,
testID,
children
children,
...otherProps
}: PropsWithChildren<
Pick<IMGElementProps, 'onPress' | 'testID'> & { style: ViewStyle }
Pick<IMGElementProps, 'onPress' | 'testID'> &
Omit<ViewProps, 'style'> & { style: ViewStyle }
>): ReactElement {
const containerStyle = useMemo(() => {
const { width, height, ...remainingStyle } = style;
Expand All @@ -35,7 +37,7 @@ export default function IMGElementContainer({
typeof onPress === 'function' ? GenericPressable : View;
return React.createElement(
Container,
{ style: containerStyle, onPress, testID },
{ ...otherProps, style: containerStyle, onPress, testID },
children
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,10 @@ import { IMGElementStateLoading } from './img-types';
*/
export default function IMGElementContentLoading({
dimensions,
alt,
children
}: PropsWithChildren<IMGElementStateLoading>): ReactElement {
return (
<View
style={dimensions}
accessibilityRole="image"
accessibilityLabel={alt}
testID="image-loading">
<View style={dimensions} testID="image-loading">
{children}
</View>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ const defaultImageStyle: ImageStyle = { resizeMode: 'cover' };
* Default success "image" view for the {@link IMGElement} component.
*/
export default function IMGElementContentSuccess({
alt,
source,
imageStyle,
dimensions,
Expand All @@ -26,8 +25,6 @@ export default function IMGElementContentSuccess({
);
return (
<Image
accessibilityRole="image"
accessibilityLabel={alt}
source={source}
onError={onImageError}
style={[defaultImageStyle, dimensions, imageStyle]}
Expand Down
2 changes: 2 additions & 0 deletions packages/render-html/src/elements/img-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
ImageURISource,
PressableProps,
StyleProp,
ViewProps,
ViewStyle
} from 'react-native';
import { ImageDimensions } from '../shared-types';
Expand Down Expand Up @@ -76,6 +77,7 @@ export interface UseIMGElementStateProps {
* Props for the {@link IMGElement} component.
*/
export interface IMGElementProps extends UseIMGElementStateProps {
containerProps?: Omit<ViewProps, 'style'>;
/**
* A callback triggered on press.
*/
Expand Down
22 changes: 22 additions & 0 deletions packages/render-html/src/helpers/getNativePropsForTNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { TBlock, TPhrasing, TText } from '@native-html/transient-render-engine';
import { TDefaultRendererProps } from '../shared-types';

/**
* Extract React Native props for a given {@link TNode}. Native props target
* either `Text` or `View` elements, with an optional `onPress` prop for
* interactive elements.
*/
export default function getNativePropsForTNode(
props: TDefaultRendererProps<TPhrasing | TText | TBlock>
) {
const { tnode, style, type, nativeProps, onPress } = props;
const switchProp = type === 'block' ? props.viewProps : props.textProps;
return {
...tnode.getReactNativeProps()?.[type === 'block' ? 'view' : 'text'],
...nativeProps,
...switchProp,
onPress,
style: [style, nativeProps?.style, switchProp.style],
testID: tnode.tagName || undefined
};
}
23 changes: 3 additions & 20 deletions packages/render-html/src/renderTextualContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,13 @@ import React, { ReactNode } from 'react';
import { Text } from 'react-native';
import { TPhrasing, TText } from '@native-html/transient-render-engine';
import { TDefaultRendererProps } from './shared-types';
import getNativePropsForTNode from './helpers/getNativePropsForTNode';

const renderTextualContent = (
{
tnode,
style,
textProps,
nativeProps,
onPress
}: TDefaultRendererProps<TPhrasing | TText>,
props: TDefaultRendererProps<TPhrasing | TText>,
children: ReactNode
) => {
const resolvedStyles = [style, nativeProps?.style, textProps.style];
return React.createElement(
Text,
{
...tnode.getReactNativeProps()?.text,
...nativeProps,
...textProps,
onPress,
style: resolvedStyles,
testID: tnode.tagName || undefined
},
children
);
return React.createElement(Text, getNativePropsForTNode(props), children);
};

export default renderTextualContent;
24 changes: 16 additions & 8 deletions packages/render-html/src/renderers/IMGRenderer.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import React from 'react';
import React, { useMemo } from 'react';
import { TBlock } from '@native-html/transient-render-engine';
import IMGElement, { IMGElementProps } from '../elements/IMGElement';
import { InternalBlockRenderer } from '../render/render-types';
import { useComputeMaxWidthForTag } from '../context/SharedPropsProvider';
import { ImageStyle } from 'react-native';
import { ImageStyle, StyleSheet } from 'react-native';
import { InternalRendererProps } from '../shared-types';
import useNormalizedUrl from '../hooks/useNormalizedUrl';
import { useRendererProps } from '../context/RenderersPropsProvider';
import useContentWidth from '../hooks/useContentWidth';
import getNativePropsForTNode from '../helpers/getNativePropsForTNode';

/**
* A hook to produce props consumable by {@link IMGElement} component
Expand All @@ -16,23 +17,30 @@ import useContentWidth from '../hooks/useContentWidth';
export function useIMGElementProps(
props: InternalRendererProps<TBlock>
): IMGElementProps {
const { style, tnode, onPress } = props;
const { tnode } = props;

const contentWidth = useContentWidth();
const { initialDimensions, enableExperimentalPercentWidth } =
useRendererProps('img');
const computeImagesMaxWidth = useComputeMaxWidthForTag('img');
const src = tnode.attributes.src || '';
const source = { uri: useNormalizedUrl(src) };
const { style: rawStyle, ...containerProps } = getNativePropsForTNode(props);
const style = useMemo<ImageStyle>(
() => (rawStyle ? (StyleSheet.flatten(rawStyle) as ImageStyle) : {}),
[rawStyle]
);
return {
contentWidth,
computeMaxWidth: computeImagesMaxWidth,
containerProps,
enableExperimentalPercentWidth,
initialDimensions,
onPress,
alt: tnode.attributes.alt,
source,
style,
testID: 'img',
computeMaxWidth: computeImagesMaxWidth,
alt: tnode.attributes.alt,
altColor: tnode.styles.nativeTextFlow.color as string,
source: { uri: useNormalizedUrl(src) },
style: style as ImageStyle,
width: tnode.attributes.width,
height: tnode.attributes.height,
objectFit: tnode.styles.webBlockRet.objectFit
Expand Down

0 comments on commit 7fc2907

Please sign in to comment.