Repository navigation
fix: define avatar identity and fallback semantics #5058
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
5daa8b8
3e9a6e3
df2efb2
73af3dd
482e436
5ed8a6f
ce5ea36
64c33a8
0e7c864
df10c5d
ea72607
d7c34ee
4dd612d
775f2d2
5edcd51
16ae54e
2686354
5e6c591
bd7d35c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,26 +8,75 @@ import type { | |
| ViewStyle, | ||
| } from 'react-native'; | ||
|
|
||
| import { | ||
| DEFAULT_SIZE, | ||
| getAvatarImageSourceKey, | ||
| getAvatarRole, | ||
| resolveAvatarColors, | ||
| } from './utils'; | ||
| import { useInternalTheme } from '../../core/theming'; | ||
| import { cornerFull } from '../../theme/tokens/sys/shape'; | ||
| import type { ThemeProp } from '../../theme/types'; | ||
|
|
||
| const defaultSize = 64; | ||
| export type AvatarImageSourceProps = { | ||
| size: number; | ||
| style: { width: number; height: number; borderRadius: number }; | ||
| onError?: ImageProps['onError']; | ||
| /** | ||
| * Present when the host received a description through `alt`, `aria-label` | ||
| * or `accessibilityLabel`. Pass it to your image so it is announced by | ||
| * assistive technology. | ||
| */ | ||
| alt?: string; | ||
| /** | ||
| * Mirrors `alt`. `react-native-web` fills the rendered `<img alt>` from | ||
| * `aria-label` only, so pass both to your image. | ||
| */ | ||
| 'aria-label'?: string; | ||
| /** | ||
| * `false` when the host received no description, marking the image as | ||
| * decorative. | ||
| */ | ||
| accessible?: boolean; | ||
| }; | ||
|
|
||
| export type AvatarImageSource = | ||
| | ImageSourcePropType | ||
| | ((props: { size: number }) => React.ReactNode); | ||
| | ((props: AvatarImageSourceProps) => React.ReactNode); | ||
|
|
||
| export type Props = ViewProps & { | ||
| /** | ||
| * Image to display for the `Avatar`. | ||
| * It accepts a standard React Native Image `source` prop | ||
| * Or a function that returns an `Image`. | ||
| * or a function that returns an image component. | ||
| * Function sources receive `{ size, style, onError, alt, 'aria-label', | ||
| * accessible }` matching the host avatar. Apply `style` so the image fills | ||
| * the circle, forward the accessibility props for assistive technology, and | ||
| * call `onError` to trigger `fallback`. | ||
| */ | ||
| source: AvatarImageSource; | ||
| /** | ||
| * Identity of the image that a function `source` renders, for example the | ||
| * user id. When it changes, a failed function source loads again instead of | ||
| * keeping `fallback`. Without it, `fallback` stays after a function source | ||
| * fails. Other sources do not use it, because their `uri` or module id | ||
| * identifies them. | ||
| */ | ||
| sourceKey?: string | number; | ||
| /** | ||
| * Size of the avatar. | ||
| */ | ||
| size?: number; | ||
| /** | ||
| * Text describing the image for assistive technology. `aria-label` and | ||
| * `accessibilityLabel` are treated the same way. | ||
| */ | ||
| alt?: string; | ||
| /** | ||
| * Content shown when the image fails to load. | ||
| * Receives host `size` so custom content can match the avatar. | ||
| */ | ||
| fallback?: (props: { size: number }) => React.ReactNode; | ||
| style?: StyleProp<ViewStyle>; | ||
| /** | ||
| * Invoked on load error. | ||
|
|
@@ -68,14 +117,46 @@ export type Props = ViewProps & { | |
| * import { Avatar } from 'react-native-paper'; | ||
| * | ||
| * const MyComponent = () => ( | ||
| * <Avatar.Image size={24} source={require('../assets/avatar.png')} /> | ||
| * <Avatar.Image size={24} source={require('../assets/avatar.png')} alt="Jane Doe" /> | ||
| * ); | ||
| * export default MyComponent | ||
| * ``` | ||
| * | ||
| * Pass `alt` to describe the image to assistive technology — `aria-label` and | ||
| * `accessibilityLabel` work the same way. Avatars without a description are | ||
| * treated as decorative and skipped by screen readers. | ||
| * | ||
| * Show another avatar when the image fails to load: | ||
| * ```js | ||
| * <Avatar.Image | ||
| * size={64} | ||
| * source={{ uri: user.avatarUrl }} | ||
| * alt="Jane Doe" | ||
| * fallback={({ size }) => <Avatar.Text size={size} label="JD" />} | ||
| * /> | ||
| * ``` | ||
| * | ||
| * Custom image components should apply the host `style`, `onError` and the | ||
| * accessibility props: | ||
| * ```js | ||
| * <Avatar.Image | ||
| * size={64} | ||
| * alt="Jane Doe" | ||
| * source={({ size, style, onError, ...a11y }) => ( | ||
| * <CustomImage source={{ uri }} style={style} onError={onError} {...a11y} /> | ||
| * )} | ||
| * fallback={({ size }) => <Avatar.Text size={size} label="JD" />} | ||
| * /> | ||
| * ``` | ||
| */ | ||
| const AvatarImage = ({ | ||
| size = defaultSize, | ||
| size = DEFAULT_SIZE, | ||
| source, | ||
| sourceKey: sourceKeyProp, | ||
| fallback, | ||
| alt, | ||
| 'aria-label': ariaLabel, | ||
| accessibilityLabel, | ||
| style, | ||
| onError, | ||
| onLayout, | ||
|
|
@@ -87,41 +168,94 @@ const AvatarImage = ({ | |
| testID, | ||
| ...rest | ||
| }: Props) => { | ||
| const { colors } = useInternalTheme(themeOverrides); | ||
| const { backgroundColor = colors?.primary } = StyleSheet.flatten(style) || {}; | ||
| const theme = useInternalTheme(themeOverrides); | ||
| const { backgroundColor } = StyleSheet.flatten(style) || {}; | ||
| const { background } = resolveAvatarColors({ theme, backgroundColor }); | ||
| const imageStyle = { | ||
| width: size, | ||
| height: size, | ||
| borderRadius: cornerFull, | ||
| }; | ||
| const description = alt ?? ariaLabel ?? accessibilityLabel; | ||
| const imageA11y = | ||
| description === undefined | ||
| ? { accessible: false as const } | ||
| : { alt: description, 'aria-label': description }; | ||
| const sourceKey = getAvatarImageSourceKey(source, sourceKeyProp); | ||
| const [failedSource, setFailedSource] = React.useState<{ key: unknown }>(); | ||
| const hasError = | ||
| failedSource !== undefined && Object.is(failedSource.key, sourceKey); | ||
|
|
||
| const handleError: ImageProps['onError'] = (event) => { | ||
| setFailedSource({ key: sourceKey }); | ||
| onError?.(event); | ||
| }; | ||
|
|
||
| const showImage = !(hasError && fallback !== undefined); | ||
|
|
||
| const hostA11y = showImage | ||
| ? { accessible: false, importantForAccessibility: 'no' as const } | ||
| : description !== undefined | ||
| ? { | ||
| accessible: true, | ||
| 'aria-label': description, | ||
| ...getAvatarRole(rest.accessibilityRole), | ||
| } | ||
| : {}; | ||
|
Comment on lines
+196
to
+204
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When : description !== undefined
- ? { accessible: true, 'aria-label': description }
+ ? { accessible: true, role: 'img' as const, 'aria-label': description }
: {};
...
- {!showImage ? fallback({ size }) : null}
+ {!showImage ? (
+ <View aria-hidden={description !== undefined}>
+ {fallback({ size })}
+ </View>
+ ) : null}(via Claude, verify if it makes sense)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed |
||
|
|
||
| return ( | ||
| <View | ||
| style={[ | ||
| { | ||
| width: size, | ||
| height: size, | ||
| borderRadius: size / 2, | ||
| backgroundColor, | ||
| borderRadius: cornerFull, | ||
| backgroundColor: background, | ||
| }, | ||
| styles.container, | ||
| style, | ||
| ]} | ||
| {...hostA11y} | ||
| {...rest} | ||
| > | ||
| {typeof source === 'function' && source({ size })} | ||
| {typeof source !== 'function' && ( | ||
| {showImage && typeof source === 'function' | ||
| ? source({ | ||
| size, | ||
| style: imageStyle, | ||
| onError: handleError, | ||
| ...imageA11y, | ||
| }) | ||
| : null} | ||
| {showImage && typeof source !== 'function' ? ( | ||
| <Image | ||
| testID={testID} | ||
| source={source} | ||
| style={{ width: size, height: size, borderRadius: size / 2 }} | ||
| onError={onError} | ||
| style={imageStyle} | ||
| onError={handleError} | ||
| onLayout={onLayout} | ||
| onLoad={onLoad} | ||
| onLoadEnd={onLoadEnd} | ||
| onLoadStart={onLoadStart} | ||
| onProgress={onProgress} | ||
| accessibilityIgnoresInvertColors | ||
| {...imageA11y} | ||
| /> | ||
| )} | ||
| ) : null} | ||
| {!showImage ? ( | ||
| <View aria-hidden={description !== undefined}> | ||
| {fallback({ size })} | ||
| </View> | ||
| ) : null} | ||
| </View> | ||
| ); | ||
| }; | ||
|
|
||
| AvatarImage.displayName = 'Avatar.Image'; | ||
|
|
||
| const styles = StyleSheet.create({ | ||
| container: { | ||
| overflow: 'hidden', | ||
| }, | ||
| }); | ||
|
|
||
| export default AvatarImage; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fold
aria-label/accessibilityLabelintoalthere. They currently land on a host that isaccessible={false}importantForAccessibility="no", so nothing announces on native - whileAvatar.TextandAvatar.Iconin this PR honour both. Your own icon test says why: "Viewis not an accessibility element by default".