From 5daa8b8f08748296f2cbd284a477b22ae3b86e43 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Wed, 19 Aug 2026 16:10:36 +0200 Subject: [PATCH 1/8] fix: move accessibility props from avatar view to its image --- src/components/Avatar/AvatarImage.tsx | 6 +- src/components/__tests__/Avatar.test.tsx | 36 ++++++++ .../__snapshots__/Avatar.test.tsx.snap | 1 + .../__tests__/splitAccessibilityProps.test.ts | 44 ++++++++++ src/utils/splitAccessibilityProps.ts | 82 +++++++++++++++++++ 5 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/splitAccessibilityProps.test.ts create mode 100644 src/utils/splitAccessibilityProps.ts diff --git a/src/components/Avatar/AvatarImage.tsx b/src/components/Avatar/AvatarImage.tsx index 51330dd70c..4d4e88daa5 100644 --- a/src/components/Avatar/AvatarImage.tsx +++ b/src/components/Avatar/AvatarImage.tsx @@ -10,6 +10,7 @@ import type { import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; +import { splitAccessibilityProps } from '../../utils/splitAccessibilityProps'; const defaultSize = 64; @@ -89,6 +90,7 @@ const AvatarImage = ({ }: Props) => { const { colors } = useInternalTheme(themeOverrides); const { backgroundColor = colors?.primary } = StyleSheet.flatten(style) || {}; + const { accessibilityProps, rest: viewProps } = splitAccessibilityProps(rest); return ( {typeof source === 'function' && source({ size })} {typeof source !== 'function' && ( @@ -116,6 +119,7 @@ const AvatarImage = ({ onLoadStart={onLoadStart} onProgress={onProgress} accessibilityIgnoresInvertColors + {...accessibilityProps} /> )} diff --git a/src/components/__tests__/Avatar.test.tsx b/src/components/__tests__/Avatar.test.tsx index dc437b2dd9..53a10dade7 100644 --- a/src/components/__tests__/Avatar.test.tsx +++ b/src/components/__tests__/Avatar.test.tsx @@ -168,3 +168,39 @@ describe('AvatarImage listener', () => { expect(onListenerMock).toHaveBeenCalled(); }); }); + +it('forwards accessibility props to the image, not the wrapper', async () => { + const tree = ( + await render( + + ) + ).toJSON(); + + expect(tree).toMatchObject({ + props: { + importantForAccessibility: 'no', + }, + children: [ + { + props: { + accessibilityLabel: 'Profile photo', + accessibilityHint: 'User avatar', + accessibilityRole: 'image', + 'aria-label': 'Jane Doe', + }, + }, + ], + }); + expect(tree).not.toMatchObject({ + props: { + accessibilityLabel: 'Profile photo', + }, + }); +}); diff --git a/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap b/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap index 9e55666cef..239ff8ae1c 100644 --- a/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap @@ -102,6 +102,7 @@ exports[`renders avatar with icon and custom background color 1`] = ` exports[`renders avatar with image 1`] = ` { + it('moves accessibility props out of rest', () => { + const onAccessibilityAction = () => {}; + const { accessibilityProps, rest } = splitAccessibilityProps({ + accessibilityLabel: 'Profile photo', + accessibilityHint: 'User avatar', + accessibilityRole: 'image', + 'aria-label': 'Jane Doe', + role: 'img', + onAccessibilityAction, + pointerEvents: 'none', + collapsable: false, + }); + + expect(accessibilityProps).toEqual({ + accessibilityLabel: 'Profile photo', + accessibilityHint: 'User avatar', + accessibilityRole: 'image', + 'aria-label': 'Jane Doe', + role: 'img', + onAccessibilityAction, + }); + expect(rest).toEqual({ + pointerEvents: 'none', + collapsable: false, + }); + }); + + it('omits undefined accessibility values', () => { + const { accessibilityProps, rest } = splitAccessibilityProps({ + accessibilityLabel: undefined, + pointerEvents: 'box-none', + }); + + expect(accessibilityProps).toEqual({}); + expect(rest).toEqual({ + pointerEvents: 'box-none', + }); + }); +}); diff --git a/src/utils/splitAccessibilityProps.ts b/src/utils/splitAccessibilityProps.ts new file mode 100644 index 0000000000..19d8c9ba61 --- /dev/null +++ b/src/utils/splitAccessibilityProps.ts @@ -0,0 +1,82 @@ +import type { AccessibilityProps } from 'react-native'; + +/** + * Accessibility props that are present on the `AccessibilityProps` interface. + */ +const ACCESSIBILITY_PROP_PRESENCE = { + accessible: true, + accessibilityActions: true, + accessibilityLabel: true, + 'aria-label': true, + accessibilityRole: true, + accessibilityState: true, + 'aria-busy': true, + 'aria-checked': true, + 'aria-disabled': true, + 'aria-expanded': true, + 'aria-selected': true, + accessibilityHint: true, + accessibilityValue: true, + 'aria-valuemax': true, + 'aria-valuemin': true, + 'aria-valuenow': true, + 'aria-valuetext': true, + onAccessibilityAction: true, + importantForAccessibility: true, + 'aria-hidden': true, + 'aria-modal': true, + role: true, + accessibilityLabelledBy: true, + 'aria-labelledby': true, + accessibilityLiveRegion: true, + 'aria-live': true, + screenReaderFocusable: true, + accessibilityElementsHidden: true, + accessibilityViewIsModal: true, + onAccessibilityEscape: true, + onAccessibilityTap: true, + onMagicTap: true, + accessibilityIgnoresInvertColors: true, + accessibilityLanguage: true, + accessibilityShowsLargeContentViewer: true, + accessibilityLargeContentTitle: true, + accessibilityRespondsToUserInteraction: true, +} satisfies Record; + +/** + * Keys of the `AccessibilityProps` interface. + */ +const ACCESSIBILITY_PROP_KEYS = Object.keys( + ACCESSIBILITY_PROP_PRESENCE +) as (keyof AccessibilityProps)[]; + +/** + * Splits the accessibility props from the rest of the props. + * @param props - The props to split. + * @returns The accessibility props and the rest of the props. + */ +export function splitAccessibilityProps( + props: T +) { + const accessibilityProps: AccessibilityProps = {}; + const rest = { ...props }; + + for (const key of ACCESSIBILITY_PROP_KEYS) { + if (!Object.hasOwn(rest, key)) { + continue; + } + + const value = rest[key]; + if (value !== undefined) { + (accessibilityProps as Record)[key] = + value; + } + + delete rest[key]; + } + + return { + accessibilityProps, + rest: rest as Omit, + }; +} From 3e9a6e3be9f0f37a32863b4c87bd04f489e0915b Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Wed, 19 Aug 2026 17:10:45 +0200 Subject: [PATCH 2/8] fix: make avatar initials grapheme-safe --- src/components/Avatar/AvatarText.tsx | 4 ++- src/utils/__tests__/takeGraphemes.test.ts | 39 +++++++++++++++++++++++ src/utils/takeGraphemes.ts | 36 +++++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/takeGraphemes.test.ts create mode 100644 src/utils/takeGraphemes.ts diff --git a/src/components/Avatar/AvatarText.tsx b/src/components/Avatar/AvatarText.tsx index d9e16c8090..a3ad72729c 100644 --- a/src/components/Avatar/AvatarText.tsx +++ b/src/components/Avatar/AvatarText.tsx @@ -5,6 +5,7 @@ import { useInternalTheme } from '../../core/theming'; import { white } from '../../theme/colors'; import type { ThemeProp } from '../../types'; import getContrastingColor from '../../utils/getContrastingColor'; +import { takeGraphemes } from '../../utils/takeGraphemes'; import Text from '../Typography/Text'; const defaultSize = 64; @@ -70,6 +71,7 @@ const AvatarText = ({ customColor ?? getContrastingColor(backgroundColor, white, 'rgba(0, 0, 0, .54)'); const { fontScale } = useWindowDimensions(); + const avatarInitials = takeGraphemes(label, 2); return ( - {label} + {avatarInitials} ); diff --git a/src/utils/__tests__/takeGraphemes.test.ts b/src/utils/__tests__/takeGraphemes.test.ts new file mode 100644 index 0000000000..9f480663db --- /dev/null +++ b/src/utils/__tests__/takeGraphemes.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from '@jest/globals'; + +import { takeGraphemes } from '../takeGraphemes'; + +describe('takeGraphemes', () => { + it('returns ASCII characters by count', () => { + expect(takeGraphemes('XD', 2)).toBe('XD'); + expect(takeGraphemes('Hello', 2)).toBe('He'); + }); + + it('returns an empty string for empty input or non-positive count', () => { + expect(takeGraphemes('', 2)).toBe(''); + expect(takeGraphemes('XD', 0)).toBe(''); + expect(takeGraphemes('XD', -1)).toBe(''); + }); + + it('keeps combining marks attached to the base character', () => { + expect(takeGraphemes('e\u0301va', 1)).toBe('e\u0301'); + expect(takeGraphemes('e\u0301va', 2)).toBe('e\u0301v'); + }); + + it('does not split surrogate-pair emoji', () => { + expect(takeGraphemes('😀😃', 1)).toBe('😀'); + expect(takeGraphemes('😀😃', 2)).toBe('😀😃'); + }); + + it('keeps emoji skin tones as a single grapheme', () => { + expect(takeGraphemes('👍🏽👍', 1)).toBe('👍🏽'); + }); + + it('keeps ZWJ emoji sequences as a single grapheme', () => { + expect(takeGraphemes('👨‍👩‍👧X', 1)).toBe('👨‍👩‍👧'); + }); + + it('keeps flag emoji as a single grapheme', () => { + expect(takeGraphemes('🇪🇺X', 1)).toBe('🇪🇺'); + expect(takeGraphemes('🇪🇺X', 2)).toBe('🇪🇺X'); + }); +}); diff --git a/src/utils/takeGraphemes.ts b/src/utils/takeGraphemes.ts new file mode 100644 index 0000000000..80a7ab4ced --- /dev/null +++ b/src/utils/takeGraphemes.ts @@ -0,0 +1,36 @@ +const GRAPHEME_PATTERN = + '\\p{Regional_Indicator}{2}|' + + '\\p{Extended_Pictographic}(?:\\p{Emoji_Modifier}|\\p{M})*(?:\\u200D\\p{Extended_Pictographic}(?:\\p{Emoji_Modifier}|\\p{M})*)*|' + + '\\P{M}\\p{M}*|' + + '.'; + +/** + * Returns a regular expression that matches grapheme clusters. + */ +function getGraphemeRegExp(): RegExp | undefined { + try { + return new RegExp(GRAPHEME_PATTERN, 'gu'); + } catch { + return undefined; + } +} + +const graphemeRegExp = getGraphemeRegExp(); + +/** + * Returns the first `count` user-perceived characters (grapheme clusters). + * + * Handles combining marks, emoji (including ZWJ sequences and skin tones), + * and flag emoji. + */ +export function takeGraphemes(value: string, count: number): string { + if (count <= 0 || value === '') { + return ''; + } + + const matches = graphemeRegExp + ? value.match(graphemeRegExp) + : Array.from(value); + + return (matches ?? []).slice(0, count).join(''); +} From df2efb2ff942f6b63b7fcf3415128ba14f893a6f Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Thu, 20 Aug 2026 13:01:12 +0200 Subject: [PATCH 3/8] feat: add an avatar image fallback API --- src/components/Avatar/AvatarImage.tsx | 89 ++++++++- src/components/__tests__/Avatar.test.tsx | 169 +++++++++++++++++- .../__snapshots__/Avatar.test.tsx.snap | 4 + 3 files changed, 253 insertions(+), 9 deletions(-) diff --git a/src/components/Avatar/AvatarImage.tsx b/src/components/Avatar/AvatarImage.tsx index 4d4e88daa5..2add5f0102 100644 --- a/src/components/Avatar/AvatarImage.tsx +++ b/src/components/Avatar/AvatarImage.tsx @@ -16,19 +16,31 @@ const defaultSize = 64; export type AvatarImageSource = | ImageSourcePropType - | ((props: { size: number }) => React.ReactNode); + | ((props: { + size: number; + style: { width: number; height: number; borderRadius: number }; + onError?: ImageProps['onError']; + }) => 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 }` matching the host avatar. + * Apply `style` so the image fills the circle, and `onError` to trigger `fallback`. + * Spread `size` from hosts such as `Card.Title` `left`. */ source: AvatarImageSource; /** * Size of the avatar. */ size?: number; + /** + * 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; /** * Invoked on load error. @@ -73,10 +85,31 @@ export type Props = ViewProps & { * ); * export default MyComponent * ``` + * + * Show another avatar when the image fails to load: + * ```js + * } + * /> + * ``` + * + * Custom image components should apply the host `style`: + * ```js + * ( + * + * )} + * fallback={({ size }) => } + * /> + * ``` */ const AvatarImage = ({ size = defaultSize, source, + fallback, style, onError, onLayout, @@ -91,6 +124,34 @@ const AvatarImage = ({ const { colors } = useInternalTheme(themeOverrides); const { backgroundColor = colors?.primary } = StyleSheet.flatten(style) || {}; const { accessibilityProps, rest: viewProps } = splitAccessibilityProps(rest); + const imageStyle = { + width: size, + height: size, + borderRadius: size / 2, + }; + const sourceKey = + source && + typeof source === 'object' && + !Array.isArray(source) && + 'uri' in source + ? source.uri + : source; + const previousSourceKey = React.useRef(sourceKey); + const [hasError, setHasError] = React.useState(false); + + if (!Object.is(previousSourceKey.current, sourceKey)) { + previousSourceKey.current = sourceKey; + if (hasError) { + setHasError(false); + } + } + + const handleError: ImageProps['onError'] = (event) => { + setHasError(true); + onError?.(event); + }; + + const showImage = !(hasError && fallback !== undefined); return ( - {typeof source === 'function' && source({ size })} - {typeof source !== 'function' && ( + {showImage && typeof source === 'function' + ? source({ size, style: imageStyle, onError: handleError }) + : null} + {showImage && typeof source !== 'function' ? ( - )} + ) : null} + {!showImage ? fallback({ size }) : null} ); }; AvatarImage.displayName = 'Avatar.Image'; +const styles = StyleSheet.create({ + container: { + overflow: 'hidden', + }, +}); + export default AvatarImage; diff --git a/src/components/__tests__/Avatar.test.tsx b/src/components/__tests__/Avatar.test.tsx index 53a10dade7..d626047fb7 100644 --- a/src/components/__tests__/Avatar.test.tsx +++ b/src/components/__tests__/Avatar.test.tsx @@ -1,4 +1,4 @@ -import { StyleSheet } from 'react-native'; +import { Image, StyleSheet } from 'react-native'; import { describe, expect, it, jest } from '@jest/globals'; import { fireEvent } from '@testing-library/react-native'; @@ -204,3 +204,170 @@ it('forwards accessibility props to the image, not the wrapper', async () => { }, }); }); + +describe('AvatarImage fallback', () => { + it('shows fallback when the image fails to load', async () => { + await render( + } + /> + ); + + await fireEvent(screen.getByTestId('avatar-image'), 'onError'); + + expect(screen.getByText('JD')).toBeTruthy(); + }); + + it('still calls onError when showing a fallback', async () => { + const onError = jest.fn(); + + await render( + } + onError={onError} + /> + ); + + await fireEvent(screen.getByTestId('avatar-image'), 'onError'); + + expect(onError).toHaveBeenCalled(); + expect(screen.getByText('JD')).toBeTruthy(); + }); + + it('keeps the image mounted and still calls onError without a fallback', async () => { + const onError = jest.fn(); + + await render( + + ); + + await fireEvent(screen.getByTestId('avatar-image'), 'onError'); + + expect(onError).toHaveBeenCalled(); + expect(screen.getByTestId('avatar-image')).toBeTruthy(); + }); + + it('retries the image when the source URI changes', async () => { + const { rerender } = await render( + } + /> + ); + + await fireEvent(screen.getByTestId('avatar-image'), 'onError'); + expect(screen.getByText('JD')).toBeTruthy(); + + await rerender( + } + /> + ); + + expect(screen.getByTestId('avatar-image')).toBeTruthy(); + expect(screen.queryByText('JD')).toBeNull(); + }); + + it('keeps the fallback when the source object identity changes', async () => { + const { rerender } = await render( + } + /> + ); + + await fireEvent(screen.getByTestId('avatar-image'), 'onError'); + + await rerender( + } + /> + ); + + expect(screen.getByText('JD')).toBeTruthy(); + }); + + it('passes host size and style to a function source', async () => { + const source = jest.fn( + ({ + style, + }: { + size: number; + style: { width: number; height: number; borderRadius: number }; + }) => ( + + ) + ); + + await render(); + + expect(source).toHaveBeenCalledWith({ + size: 48, + style: { width: 48, height: 48, borderRadius: 24 }, + onError: expect.any(Function), + }); + expect(screen.getByTestId('custom-image')).toBeTruthy(); + }); + + it('shows fallback when a function source reports an error', async () => { + await render( + ( + + )} + fallback={({ size }) => } + /> + ); + + await fireEvent(screen.getByTestId('custom-image'), 'onError'); + + expect(screen.getByText('JD')).toBeTruthy(); + expect(screen.queryByTestId('custom-image')).toBeNull(); + }); + + it('forwards accessibility props to the host when fallback is shown', async () => { + const { toJSON } = await render( + } + accessibilityLabel="Profile photo" + accessibilityRole="image" + /> + ); + + await fireEvent(screen.getByTestId('avatar-image'), 'onError'); + + expect(toJSON()).toMatchObject({ + props: { + accessibilityLabel: 'Profile photo', + accessibilityRole: 'image', + }, + }); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap b/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap index 239ff8ae1c..8202caa647 100644 --- a/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap @@ -111,12 +111,16 @@ exports[`renders avatar with image 1`] = ` "height": 64, "width": 64, }, + { + "overflow": "hidden", + }, undefined, ] } > Date: Thu, 20 Aug 2026 16:32:51 +0200 Subject: [PATCH 4/8] fix: expanded avatar text/icon contrasted color --- src/components/Avatar/AvatarIcon.tsx | 17 +-- src/components/Avatar/AvatarText.tsx | 16 +-- src/components/Avatar/utils.ts | 47 ++++++++ src/components/__tests__/AvatarUtils.test.tsx | 101 ++++++++++++++++++ src/utils/getContrastingColor.tsx | 10 +- 5 files changed, 167 insertions(+), 24 deletions(-) create mode 100644 src/components/Avatar/utils.ts create mode 100644 src/components/__tests__/AvatarUtils.test.tsx diff --git a/src/components/Avatar/AvatarIcon.tsx b/src/components/Avatar/AvatarIcon.tsx index 9c3b813e59..302a4c59d9 100644 --- a/src/components/Avatar/AvatarIcon.tsx +++ b/src/components/Avatar/AvatarIcon.tsx @@ -1,10 +1,9 @@ import { StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; +import { resolveAvatarColors } from './utils'; import { useInternalTheme } from '../../core/theming'; -import { white } from '../../theme/colors'; import type { ThemeProp } from '../../types'; -import getContrastingColor from '../../utils/getContrastingColor'; import Icon from '../Icon'; import type { IconSource } from '../Icon'; @@ -48,14 +47,16 @@ const Avatar = ({ size = defaultSize, style, theme: themeOverrides, + color: customColor, ...rest }: Props) => { const theme = useInternalTheme(themeOverrides); - const { backgroundColor = theme.colors?.primary, ...restStyle } = - StyleSheet.flatten(style) || {}; - const textColor = - rest.color ?? - getContrastingColor(backgroundColor, white, 'rgba(0, 0, 0, .54)'); + const { backgroundColor, ...restStyle } = StyleSheet.flatten(style) || {}; + const { background, textColor } = resolveAvatarColors({ + theme, + backgroundColor, + color: customColor, + }); return ( { const theme = useInternalTheme(themeOverrides); - const { backgroundColor = theme.colors?.primary, ...restStyle } = - StyleSheet.flatten(style) || {}; - const textColor = - customColor ?? - getContrastingColor(backgroundColor, white, 'rgba(0, 0, 0, .54)'); + const { backgroundColor, ...restStyle } = StyleSheet.flatten(style) || {}; + const { background, textColor } = resolveAvatarColors({ + theme, + backgroundColor, + color: customColor, + }); const { fontScale } = useWindowDimensions(); const avatarInitials = takeGraphemes(label, 2); @@ -80,7 +80,7 @@ const AvatarText = ({ width: size, height: size, borderRadius: size / 2, - backgroundColor, + backgroundColor: background, }, styles.container, restStyle, diff --git a/src/components/Avatar/utils.ts b/src/components/Avatar/utils.ts new file mode 100644 index 0000000000..7e77ab1606 --- /dev/null +++ b/src/components/Avatar/utils.ts @@ -0,0 +1,47 @@ +import type { ColorValue } from 'react-native'; + +import { white } from '../../theme/colors'; +import { contentColorFor } from '../../theme/utils/color'; +import type { InternalTheme } from '../../types'; +import getContrastingColor from '../../utils/getContrastingColor'; + +export type ResolvedAvatarColors = { + background: ColorValue; + textColor: ColorValue; +}; + +/** + * Resolve background and content colors for an avatar. + * + * - Explicit `color` wins. + * - String backgrounds keep the luminance heuristic (including string theme + * tokens such as `theme.colors.primary` in the static schemes). + * - Opaque values (`PlatformColor` / `DynamicColorIOS`) go through + * `contentColorFor`: a theme-role token pairs with its on-color; anything + * else falls back to `onSurface`. Pass `color` when that fallback is not + * appropriate. + */ +export const resolveAvatarColors = ({ + theme, + backgroundColor, + color, +}: { + theme: InternalTheme; + backgroundColor?: ColorValue; + color?: ColorValue; +}): ResolvedAvatarColors => { + const background = backgroundColor ?? theme.colors.primary; + + if (color != null) { + return { background, textColor: color }; + } + + if (typeof background === 'string') { + return { + background, + textColor: getContrastingColor(background, white, 'rgba(0, 0, 0, .54)'), + }; + } + + return { background, textColor: contentColorFor(theme, background) }; +}; diff --git a/src/components/__tests__/AvatarUtils.test.tsx b/src/components/__tests__/AvatarUtils.test.tsx new file mode 100644 index 0000000000..7164bee21b --- /dev/null +++ b/src/components/__tests__/AvatarUtils.test.tsx @@ -0,0 +1,101 @@ +import type { ColorValue } from 'react-native'; + +import { describe, expect, it } from '@jest/globals'; + +import { getTheme } from '../../core/theming'; +import { red50, red500 } from '../../theme/colors'; +import type { InternalTheme } from '../../types'; +import { resolveAvatarColors } from '../Avatar/utils'; + +const withPlatformColor = ( + theme: InternalTheme, + role: 'primary' | 'error', + resource: string +): InternalTheme => ({ + ...theme, + colors: { + ...theme.colors, + [role]: { resource_paths: [resource] } as unknown as ColorValue, + }, +}); + +describe('resolveAvatarColors', () => { + it('uses the luminance heuristic for a string default primary', () => { + const theme = getTheme(); + expect(typeof theme.colors.primary).toBe('string'); + expect(resolveAvatarColors({ theme })).toEqual({ + background: theme.colors.primary, + textColor: '#ffffff', + }); + }); + + it('pairs an opaque theme-role token via contentColorFor', () => { + const theme = withPlatformColor( + getTheme(), + 'primary', + '@android:color/system_primary_light' + ); + expect(resolveAvatarColors({ theme })).toEqual({ + background: theme.colors.primary, + textColor: theme.colors.onPrimary, + }); + }); + + it('pairs a custom opaque theme-role background via contentColorFor', () => { + const theme = withPlatformColor( + getTheme(), + 'error', + '@android:color/system_error_light' + ); + expect( + resolveAvatarColors({ theme, backgroundColor: theme.colors.error }) + ).toEqual({ + background: theme.colors.error, + textColor: theme.colors.onError, + }); + }); + + it('uses the luminance heuristic for a dark hex background', () => { + const theme = getTheme(); + expect(resolveAvatarColors({ theme, backgroundColor: red500 })).toEqual({ + background: red500, + textColor: '#ffffff', + }); + }); + + it('uses the luminance heuristic for a light hex background', () => { + const theme = getTheme(); + expect(resolveAvatarColors({ theme, backgroundColor: red50 })).toEqual({ + background: red50, + textColor: 'rgba(0, 0, 0, .54)', + }); + }); + + it('falls back to onSurface for an unknown PlatformColor', () => { + const theme = getTheme(); + const platformColor = { + resource_paths: ['@android:color/holo_blue_bright'], + } as unknown as ColorValue; + + expect( + resolveAvatarColors({ theme, backgroundColor: platformColor }) + ).toEqual({ + background: platformColor, + textColor: theme.colors.onSurface, + }); + }); + + it('lets an explicit color override derived content color', () => { + const theme = getTheme(); + expect( + resolveAvatarColors({ + theme, + backgroundColor: theme.colors.error, + color: '#00ff00', + }) + ).toEqual({ + background: theme.colors.error, + textColor: '#00ff00', + }); + }); +}); diff --git a/src/utils/getContrastingColor.tsx b/src/utils/getContrastingColor.tsx index dc2e0b3855..b185f518c9 100644 --- a/src/utils/getContrastingColor.tsx +++ b/src/utils/getContrastingColor.tsx @@ -1,15 +1,9 @@ -import type { ColorValue } from 'react-native'; - import color from 'color'; export default function getContrastingColor( - input: ColorValue, + input: string, light: string, dark: string ): string { - if (typeof input === 'string') { - return color(input).isLight() ? dark : light; - } - - return light; + return color(input).isLight() ? dark : light; } From 482e436696ad07eaf60737c36dc907848c149b2e Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Fri, 21 Aug 2026 09:36:43 +0200 Subject: [PATCH 5/8] fix: filled minor gaps of the avatar fixes --- src/components/Avatar/AvatarImage.tsx | 62 +++----- src/components/Avatar/AvatarText.tsx | 2 + src/components/Avatar/utils.ts | 21 +++ src/components/__tests__/Avatar.test.tsx | 150 +++++++++++++++++- src/components/__tests__/AvatarUtils.test.tsx | 19 ++- .../__snapshots__/Avatar.test.tsx.snap | 10 ++ 6 files changed, 217 insertions(+), 47 deletions(-) diff --git a/src/components/Avatar/AvatarImage.tsx b/src/components/Avatar/AvatarImage.tsx index 2add5f0102..28fd41b3cc 100644 --- a/src/components/Avatar/AvatarImage.tsx +++ b/src/components/Avatar/AvatarImage.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import { Image, StyleSheet, View } from 'react-native'; import type { + AccessibilityProps, ImageProps, ImageSourcePropType, StyleProp, @@ -8,28 +9,28 @@ import type { ViewStyle, } from 'react-native'; +import { getAvatarImageSourceKey } from './utils'; import { useInternalTheme } from '../../core/theming'; import type { ThemeProp } from '../../types'; import { splitAccessibilityProps } from '../../utils/splitAccessibilityProps'; const defaultSize = 64; +export type AvatarImageSourceProps = { + size: number; + style: { width: number; height: number; borderRadius: number }; + onError?: ImageProps['onError']; +} & AccessibilityProps; + export type AvatarImageSource = | ImageSourcePropType - | ((props: { - size: number; - style: { width: number; height: number; borderRadius: number }; - onError?: ImageProps['onError']; - }) => 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 component. - * Function sources receive `{ size, style, onError }` matching the host avatar. - * Apply `style` so the image fills the circle, and `onError` to trigger `fallback`. - * Spread `size` from hosts such as `Card.Title` `left`. + * Or a function that returns an `Image`. */ source: AvatarImageSource; /** @@ -85,26 +86,6 @@ export type Props = ViewProps & { * ); * export default MyComponent * ``` - * - * Show another avatar when the image fails to load: - * ```js - * } - * /> - * ``` - * - * Custom image components should apply the host `style`: - * ```js - * ( - * - * )} - * fallback={({ size }) => } - * /> - * ``` */ const AvatarImage = ({ size = defaultSize, @@ -129,13 +110,11 @@ const AvatarImage = ({ height: size, borderRadius: size / 2, }; - const sourceKey = - source && - typeof source === 'object' && - !Array.isArray(source) && - 'uri' in source - ? source.uri - : source; + const imageA11y = + Object.keys(accessibilityProps).length > 0 + ? accessibilityProps + : { accessible: false as const }; + const sourceKey = getAvatarImageSourceKey(source); const previousSourceKey = React.useRef(sourceKey); const [hasError, setHasError] = React.useState(false); @@ -167,11 +146,16 @@ const AvatarImage = ({ ]} {...viewProps} {...(showImage - ? { importantForAccessibility: 'no' as const } + ? { accessible: false, importantForAccessibility: 'no' as const } : accessibilityProps)} > {showImage && typeof source === 'function' - ? source({ size, style: imageStyle, onError: handleError }) + ? source({ + size, + style: imageStyle, + onError: handleError, + ...imageA11y, + }) : null} {showImage && typeof source !== 'function' ? ( ) : null} {!showImage ? fallback({ size }) : null} diff --git a/src/components/Avatar/AvatarText.tsx b/src/components/Avatar/AvatarText.tsx index 00fd2fd17d..fff184438d 100644 --- a/src/components/Avatar/AvatarText.tsx +++ b/src/components/Avatar/AvatarText.tsx @@ -99,6 +99,8 @@ const AvatarText = ({ ]} numberOfLines={1} maxFontSizeMultiplier={maxFontSizeMultiplier} + accessibilityElementsHidden + importantForAccessibility="no-hide-descendants" > {avatarInitials} diff --git a/src/components/Avatar/utils.ts b/src/components/Avatar/utils.ts index 7e77ab1606..5fe7d9437f 100644 --- a/src/components/Avatar/utils.ts +++ b/src/components/Avatar/utils.ts @@ -45,3 +45,24 @@ export const resolveAvatarColors = ({ return { background, textColor: contentColorFor(theme, background) }; }; + +/** + * Identity for retrying a failed avatar image. + * Function sources are keyed stably so inline renderers do not reset state. + */ +export const getAvatarImageSourceKey = (source: unknown) => { + if (typeof source === 'function') { + return 'function'; + } + + if ( + source && + typeof source === 'object' && + !Array.isArray(source) && + 'uri' in source + ) { + return (source as { uri: unknown }).uri; + } + + return source; +}; diff --git a/src/components/__tests__/Avatar.test.tsx b/src/components/__tests__/Avatar.test.tsx index d626047fb7..0af2352b87 100644 --- a/src/components/__tests__/Avatar.test.tsx +++ b/src/components/__tests__/Avatar.test.tsx @@ -7,6 +7,8 @@ import { render, screen } from '../../test-utils'; import { red500 } from '../../theme/colors'; import * as Avatar from '../Avatar/Avatar'; +const hidden = { includeHiddenElements: true }; + const styles = StyleSheet.create({ bgColor: { backgroundColor: red500, @@ -185,6 +187,7 @@ it('forwards accessibility props to the image, not the wrapper', async () => { expect(tree).toMatchObject({ props: { + accessible: false, importantForAccessibility: 'no', }, children: [ @@ -205,6 +208,65 @@ it('forwards accessibility props to the image, not the wrapper', async () => { }); }); +it('keeps an unlabeled image unfocusable', async () => { + const tree = ( + await render() + ).toJSON(); + + expect(tree).toMatchObject({ + props: { + accessible: false, + importantForAccessibility: 'no', + }, + children: [ + { + props: { + accessible: false, + }, + }, + ], + }); +}); + +it('hides text avatar initials from assistive tech', async () => { + const tree = ( + await render( + + ) + ).toJSON(); + + expect(tree).toMatchObject({ + props: { + accessibilityLabel: 'Jane Doe', + accessibilityRole: 'image', + }, + children: [ + { + props: { + accessibilityElementsHidden: true, + importantForAccessibility: 'no-hide-descendants', + }, + }, + ], + }); +}); + +it('bounds text avatar initials by grapheme', async () => { + const tree = (await render()).toJSON(); + + expect(tree).toMatchObject({ + children: [ + { + children: ['👨‍👩‍👧X'], + }, + ], + }); +}); + describe('AvatarImage fallback', () => { it('shows fallback when the image fails to load', async () => { await render( @@ -217,7 +279,7 @@ describe('AvatarImage fallback', () => { await fireEvent(screen.getByTestId('avatar-image'), 'onError'); - expect(screen.getByText('JD')).toBeTruthy(); + expect(screen.getByText('JD', hidden)).toBeTruthy(); }); it('still calls onError when showing a fallback', async () => { @@ -235,7 +297,7 @@ describe('AvatarImage fallback', () => { await fireEvent(screen.getByTestId('avatar-image'), 'onError'); expect(onError).toHaveBeenCalled(); - expect(screen.getByText('JD')).toBeTruthy(); + expect(screen.getByText('JD', hidden)).toBeTruthy(); }); it('keeps the image mounted and still calls onError without a fallback', async () => { @@ -265,7 +327,7 @@ describe('AvatarImage fallback', () => { ); await fireEvent(screen.getByTestId('avatar-image'), 'onError'); - expect(screen.getByText('JD')).toBeTruthy(); + expect(screen.getByText('JD', hidden)).toBeTruthy(); await rerender( { ); expect(screen.getByTestId('avatar-image')).toBeTruthy(); - expect(screen.queryByText('JD')).toBeNull(); + expect(screen.queryByText('JD', hidden)).toBeNull(); }); it('keeps the fallback when the source object identity changes', async () => { @@ -298,10 +360,10 @@ describe('AvatarImage fallback', () => { /> ); - expect(screen.getByText('JD')).toBeTruthy(); + expect(screen.getByText('JD', hidden)).toBeTruthy(); }); - it('passes host size and style to a function source', async () => { + it('passes host size, style, and a11y to a function source', async () => { const source = jest.fn( ({ style, @@ -324,10 +386,47 @@ describe('AvatarImage fallback', () => { size: 48, style: { width: 48, height: 48, borderRadius: 24 }, onError: expect.any(Function), + accessible: false, }); expect(screen.getByTestId('custom-image')).toBeTruthy(); }); + it('forwards accessibility props to a function source', async () => { + const source = jest.fn( + ({ + style, + ...a11y + }: { + size: number; + style: { width: number; height: number; borderRadius: number }; + }) => ( + + ) + ); + + await render( + + ); + + expect(source).toHaveBeenCalledWith( + expect.objectContaining({ + accessibilityLabel: 'Profile photo', + accessibilityRole: 'image', + }) + ); + }); + it('shows fallback when a function source reports an error', async () => { await render( { await fireEvent(screen.getByTestId('custom-image'), 'onError'); - expect(screen.getByText('JD')).toBeTruthy(); + expect(screen.getByText('JD', hidden)).toBeTruthy(); + expect(screen.queryByTestId('custom-image')).toBeNull(); + }); + + it('keeps the fallback when a function source identity changes', async () => { + const { rerender } = await render( + ( + + )} + fallback={({ size }) => } + /> + ); + + await fireEvent(screen.getByTestId('custom-image'), 'onError'); + + await rerender( + ( + + )} + fallback={({ size }) => } + /> + ); + + expect(screen.getByText('JD', hidden)).toBeTruthy(); expect(screen.queryByTestId('custom-image')).toBeNull(); }); diff --git a/src/components/__tests__/AvatarUtils.test.tsx b/src/components/__tests__/AvatarUtils.test.tsx index 7164bee21b..b174e18d87 100644 --- a/src/components/__tests__/AvatarUtils.test.tsx +++ b/src/components/__tests__/AvatarUtils.test.tsx @@ -5,7 +5,7 @@ import { describe, expect, it } from '@jest/globals'; import { getTheme } from '../../core/theming'; import { red50, red500 } from '../../theme/colors'; import type { InternalTheme } from '../../types'; -import { resolveAvatarColors } from '../Avatar/utils'; +import { resolveAvatarColors, getAvatarImageSourceKey } from '../Avatar/utils'; const withPlatformColor = ( theme: InternalTheme, @@ -99,3 +99,20 @@ describe('resolveAvatarColors', () => { }); }); }); + +describe('getAvatarImageSourceKey', () => { + it('keys object sources by uri', () => { + expect(getAvatarImageSourceKey({ uri: 'a.png' })).toBe('a.png'); + }); + + it('is stable for function sources', () => { + expect(getAvatarImageSourceKey(() => null)).toBe('function'); + expect(getAvatarImageSourceKey(() => null)).toBe( + getAvatarImageSourceKey(() => null) + ); + }); + + it('uses the value for module ids', () => { + expect(getAvatarImageSourceKey(1)).toBe(1); + }); +}); diff --git a/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap b/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap index 8202caa647..4ee24aee96 100644 --- a/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap @@ -102,6 +102,7 @@ exports[`renders avatar with icon and custom background color 1`] = ` exports[`renders avatar with image 1`] = ` Date: Fri, 21 Aug 2026 10:40:07 +0200 Subject: [PATCH 6/8] refactor: adopt MD3 avatar tokens from #5016 --- src/components/Avatar/AvatarIcon.tsx | 11 ++-- src/components/Avatar/AvatarImage.tsx | 22 ++++--- src/components/Avatar/AvatarText.tsx | 10 ++-- src/components/Avatar/utils.ts | 18 ++++-- src/components/__tests__/Avatar.test.tsx | 3 +- src/components/__tests__/AvatarUtils.test.tsx | 20 +++---- .../__snapshots__/Avatar.test.tsx.snap | 60 ++++++++++++++----- 7 files changed, 93 insertions(+), 51 deletions(-) diff --git a/src/components/Avatar/AvatarIcon.tsx b/src/components/Avatar/AvatarIcon.tsx index 302a4c59d9..0a28081a10 100644 --- a/src/components/Avatar/AvatarIcon.tsx +++ b/src/components/Avatar/AvatarIcon.tsx @@ -1,14 +1,13 @@ import { StyleSheet, View } from 'react-native'; import type { StyleProp, ViewProps, ViewStyle } from 'react-native'; -import { resolveAvatarColors } from './utils'; +import { DEFAULT_SIZE, ICON_SIZE_RATIO, resolveAvatarColors } from './utils'; import { useInternalTheme } from '../../core/theming'; +import { cornerFull } from '../../theme/tokens/sys/shape'; import type { ThemeProp } from '../../types'; import Icon from '../Icon'; import type { IconSource } from '../Icon'; -const defaultSize = 64; - export type Props = ViewProps & { /** * Icon to display for the `Avatar`. @@ -44,7 +43,7 @@ export type Props = ViewProps & { */ const Avatar = ({ icon, - size = defaultSize, + size = DEFAULT_SIZE, style, theme: themeOverrides, color: customColor, @@ -64,7 +63,7 @@ const Avatar = ({ { width: size, height: size, - borderRadius: size / 2, + borderRadius: cornerFull, backgroundColor: background, }, styles.container, @@ -72,7 +71,7 @@ const Avatar = ({ ]} {...rest} > - + ); }; diff --git a/src/components/Avatar/AvatarImage.tsx b/src/components/Avatar/AvatarImage.tsx index 28fd41b3cc..3e0a21b884 100644 --- a/src/components/Avatar/AvatarImage.tsx +++ b/src/components/Avatar/AvatarImage.tsx @@ -9,13 +9,16 @@ import type { ViewStyle, } from 'react-native'; -import { getAvatarImageSourceKey } from './utils'; +import { + DEFAULT_SIZE, + getAvatarImageSourceKey, + resolveAvatarColors, +} from './utils'; import { useInternalTheme } from '../../core/theming'; +import { cornerFull } from '../../theme/tokens/sys/shape'; import type { ThemeProp } from '../../types'; import { splitAccessibilityProps } from '../../utils/splitAccessibilityProps'; -const defaultSize = 64; - export type AvatarImageSourceProps = { size: number; style: { width: number; height: number; borderRadius: number }; @@ -88,7 +91,7 @@ export type Props = ViewProps & { * ``` */ const AvatarImage = ({ - size = defaultSize, + size = DEFAULT_SIZE, source, fallback, style, @@ -102,13 +105,14 @@ 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 { accessibilityProps, rest: viewProps } = splitAccessibilityProps(rest); const imageStyle = { width: size, height: size, - borderRadius: size / 2, + borderRadius: cornerFull, }; const imageA11y = Object.keys(accessibilityProps).length > 0 @@ -138,8 +142,8 @@ const AvatarImage = ({ { width: size, height: size, - borderRadius: size / 2, - backgroundColor, + borderRadius: cornerFull, + backgroundColor: background, }, styles.container, style, diff --git a/src/components/Avatar/AvatarText.tsx b/src/components/Avatar/AvatarText.tsx index fff184438d..b922cc7a7e 100644 --- a/src/components/Avatar/AvatarText.tsx +++ b/src/components/Avatar/AvatarText.tsx @@ -1,14 +1,13 @@ import { StyleSheet, useWindowDimensions, View } from 'react-native'; import type { StyleProp, TextStyle, ViewProps, ViewStyle } from 'react-native'; -import { resolveAvatarColors } from './utils'; +import { DEFAULT_SIZE, resolveAvatarColors } from './utils'; import { useInternalTheme } from '../../core/theming'; +import { cornerFull } from '../../theme/tokens/sys/shape'; import type { ThemeProp } from '../../types'; import { takeGraphemes } from '../../utils/takeGraphemes'; import Text from '../Typography/Text'; -const defaultSize = 64; - export type Props = ViewProps & { /** * Initials to show as the text in the `Avatar`. @@ -55,7 +54,7 @@ export type Props = ViewProps & { */ const AvatarText = ({ label, - size = defaultSize, + size = DEFAULT_SIZE, style, labelStyle, color: customColor, @@ -79,7 +78,7 @@ const AvatarText = ({ { width: size, height: size, - borderRadius: size / 2, + borderRadius: cornerFull, backgroundColor: background, }, styles.container, @@ -90,6 +89,7 @@ const AvatarText = ({ { - const background = backgroundColor ?? theme.colors.primary; + const usingDefault = backgroundColor == null; + const background = backgroundColor ?? theme.colors.primaryContainer; if (color != null) { return { background, textColor: color }; } + if (usingDefault) { + return { background, textColor: theme.colors.onPrimaryContainer }; + } + if (typeof background === 'string') { return { background, diff --git a/src/components/__tests__/Avatar.test.tsx b/src/components/__tests__/Avatar.test.tsx index 0af2352b87..f2b606bf01 100644 --- a/src/components/__tests__/Avatar.test.tsx +++ b/src/components/__tests__/Avatar.test.tsx @@ -5,6 +5,7 @@ import { fireEvent } from '@testing-library/react-native'; import { render, screen } from '../../test-utils'; import { red500 } from '../../theme/colors'; +import { cornerFull } from '../../theme/tokens/sys/shape'; import * as Avatar from '../Avatar/Avatar'; const hidden = { includeHiddenElements: true }; @@ -384,7 +385,7 @@ describe('AvatarImage fallback', () => { expect(source).toHaveBeenCalledWith({ size: 48, - style: { width: 48, height: 48, borderRadius: 24 }, + style: { width: 48, height: 48, borderRadius: cornerFull }, onError: expect.any(Function), accessible: false, }); diff --git a/src/components/__tests__/AvatarUtils.test.tsx b/src/components/__tests__/AvatarUtils.test.tsx index b174e18d87..b41e6d97c9 100644 --- a/src/components/__tests__/AvatarUtils.test.tsx +++ b/src/components/__tests__/AvatarUtils.test.tsx @@ -9,7 +9,7 @@ import { resolveAvatarColors, getAvatarImageSourceKey } from '../Avatar/utils'; const withPlatformColor = ( theme: InternalTheme, - role: 'primary' | 'error', + role: 'primary' | 'primaryContainer' | 'error', resource: string ): InternalTheme => ({ ...theme, @@ -20,24 +20,24 @@ const withPlatformColor = ( }); describe('resolveAvatarColors', () => { - it('uses the luminance heuristic for a string default primary', () => { + it('uses the MD3 container pair for the default background', () => { const theme = getTheme(); - expect(typeof theme.colors.primary).toBe('string'); + expect(typeof theme.colors.primaryContainer).toBe('string'); expect(resolveAvatarColors({ theme })).toEqual({ - background: theme.colors.primary, - textColor: '#ffffff', + background: theme.colors.primaryContainer, + textColor: theme.colors.onPrimaryContainer, }); }); - it('pairs an opaque theme-role token via contentColorFor', () => { + it('uses onPrimaryContainer for an opaque default container token', () => { const theme = withPlatformColor( getTheme(), - 'primary', - '@android:color/system_primary_light' + 'primaryContainer', + '@android:color/system_primary_container_light' ); expect(resolveAvatarColors({ theme })).toEqual({ - background: theme.colors.primary, - textColor: theme.colors.onPrimary, + background: theme.colors.primaryContainer, + textColor: theme.colors.onPrimaryContainer, }); }); diff --git a/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap b/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap index 4ee24aee96..da4d4c4726 100644 --- a/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap @@ -5,8 +5,8 @@ exports[`renders avatar with icon 1`] = ` style={ [ { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 32, + "backgroundColor": "rgba(234, 221, 255, 1)", + "borderRadius": 9999, "height": 64, "width": 64, }, @@ -26,7 +26,7 @@ exports[`renders avatar with icon 1`] = ` style={ [ { - "color": "#ffffff", + "color": "rgba(33, 0, 93, 1)", "fontSize": 38.4, }, [ @@ -56,7 +56,7 @@ exports[`renders avatar with icon and custom background color 1`] = ` [ { "backgroundColor": "#f44336", - "borderRadius": 32, + "borderRadius": 9999, "height": 64, "width": 64, }, @@ -107,8 +107,8 @@ exports[`renders avatar with image 1`] = ` style={ [ { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 32, + "backgroundColor": "rgba(234, 221, 255, 1)", + "borderRadius": 9999, "height": 64, "width": 64, }, @@ -130,7 +130,7 @@ exports[`renders avatar with image 1`] = ` } style={ { - "borderRadius": 32, + "borderRadius": 9999, "height": 64, "width": 64, } @@ -144,8 +144,8 @@ exports[`renders avatar with text 1`] = ` style={ [ { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 32, + "backgroundColor": "rgba(234, 221, 255, 1)", + "borderRadius": 9999, "height": 64, "width": 64, }, @@ -181,7 +181,14 @@ exports[`renders avatar with text 1`] = ` "textAlignVertical": "center", }, { - "color": "#ffffff", + "fontFamily": "System", + "fontSize": 16, + "fontWeight": "500", + "letterSpacing": 0.15, + "lineHeight": 24, + }, + { + "color": "rgba(33, 0, 93, 1)", "fontSize": 32, "lineHeight": 64, }, @@ -201,7 +208,7 @@ exports[`renders avatar with text and custom background color 1`] = ` [ { "backgroundColor": "#f44336", - "borderRadius": 32, + "borderRadius": 9999, "height": 64, "width": 64, }, @@ -236,6 +243,13 @@ exports[`renders avatar with text and custom background color 1`] = ` "textAlign": "center", "textAlignVertical": "center", }, + { + "fontFamily": "System", + "fontSize": 16, + "fontWeight": "500", + "letterSpacing": 0.15, + "lineHeight": 24, + }, { "color": "#ffffff", "fontSize": 32, @@ -256,8 +270,8 @@ exports[`renders avatar with text and custom colors 1`] = ` style={ [ { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 32, + "backgroundColor": "rgba(234, 221, 255, 1)", + "borderRadius": 9999, "height": 64, "width": 64, }, @@ -292,6 +306,13 @@ exports[`renders avatar with text and custom colors 1`] = ` "textAlign": "center", "textAlignVertical": "center", }, + { + "fontFamily": "System", + "fontSize": 16, + "fontWeight": "500", + "letterSpacing": 0.15, + "lineHeight": 24, + }, { "color": "#FFFFFF", "fontSize": 32, @@ -312,8 +333,8 @@ exports[`renders avatar with text and custom size 1`] = ` style={ [ { - "backgroundColor": "rgba(103, 80, 164, 1)", - "borderRadius": 48, + "backgroundColor": "rgba(234, 221, 255, 1)", + "borderRadius": 9999, "height": 96, "width": 96, }, @@ -349,7 +370,14 @@ exports[`renders avatar with text and custom size 1`] = ` "textAlignVertical": "center", }, { - "color": "#ffffff", + "fontFamily": "System", + "fontSize": 16, + "fontWeight": "500", + "letterSpacing": 0.15, + "lineHeight": 24, + }, + { + "color": "rgba(33, 0, 93, 1)", "fontSize": 48, "lineHeight": 96, }, From ce5ea36635fd8f3114d69e49461498a03bb1be7b Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Fri, 21 Aug 2026 13:08:35 +0200 Subject: [PATCH 7/8] docs: update the docs to represent avatar changes --- .../docs/components/Avatar/AvatarImage.mdx | 28 ++++++++ docs/src/data/componentDocs6x.json | 66 ++++++++++++++----- src/components/Avatar/AvatarIcon.tsx | 10 ++- src/components/Avatar/AvatarImage.tsx | 24 ++++++- src/components/Avatar/AvatarText.tsx | 9 ++- 5 files changed, 118 insertions(+), 19 deletions(-) diff --git a/docs/6.x/docs/components/Avatar/AvatarImage.mdx b/docs/6.x/docs/components/Avatar/AvatarImage.mdx index ecd4bcc751..4bc65699b4 100644 --- a/docs/6.x/docs/components/Avatar/AvatarImage.mdx +++ b/docs/6.x/docs/components/Avatar/AvatarImage.mdx @@ -27,6 +27,26 @@ const MyComponent = () => ( export default MyComponent ``` +Show another avatar when the image fails to load: +```js + } +/> +``` + +Custom image components should apply the host `style` and `onError`: +```js + ( + + )} + fallback={({ size }) => } +/> +``` + ## Props @@ -51,6 +71,14 @@ export default MyComponent
+### fallback + +
+ + + +
+ ### style
diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json index 56b356912b..d9f97d9a4c 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -751,8 +751,8 @@ }, "description": "Size of the avatar.", "defaultValue": { - "value": "64", - "computed": false + "value": "DEFAULT_SIZE", + "computed": true } }, "color": { @@ -760,7 +760,7 @@ "tsType": { "name": "string" }, - "description": "Custom color for the icon." + "description": "Custom color for the icon. Takes precedence over the automatic contrast\ncolor below." }, "style": { "required": false, @@ -773,7 +773,7 @@ ], "raw": "StyleProp" }, - "description": "" + "description": "Style for the icon container. A custom `backgroundColor` is\nautomatically paired with a contrasting icon color when `color` is not\nset: string values use a luminance heuristic, while opaque/dynamic\nvalues (`PlatformColor` / `DynamicColorIOS`) are paired with a theme\nrole's `on-` color, falling back to `onSurface`." }, "theme": { "required": false, @@ -793,10 +793,10 @@ "Avatar/AvatarImage": { "filepath": "Avatar/AvatarImage.tsx", "title": "Avatar.Image", - "description": "Avatars can be used to represent people in a graphical way.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Avatar } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n);\nexport default MyComponent\n```", + "description": "Avatars can be used to represent people in a graphical way.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Avatar } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n);\nexport default MyComponent\n```\n\nShow another avatar when the image fails to load:\n```js\n }\n/>\n```\n\nCustom image components should apply the host `style` and `onError`:\n```js\n (\n \n )}\n fallback={({ size }) => }\n/>\n```", "link": "avatar-image", "data": { - "description": "Avatars can be used to represent people in a graphical way.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Avatar } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n);\nexport default MyComponent\n```", + "description": "Avatars can be used to represent people in a graphical way.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Avatar } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n);\nexport default MyComponent\n```\n\nShow another avatar when the image fails to load:\n```js\n }\n/>\n```\n\nCustom image components should apply the host `style` and `onError`:\n```js\n (\n \n )}\n fallback={({ size }) => }\n/>\n```", "displayName": "Avatar.Image", "methods": [], "statics": [], @@ -805,7 +805,7 @@ "required": true, "tsType": { "name": "union", - "raw": "| ImageSourcePropType\n| ((props: { size: number }) => React.ReactNode)", + "raw": "| ImageSourcePropType\n| ((props: AvatarImageSourceProps) => React.ReactNode)", "elements": [ { "name": "ImageSourcePropType" @@ -815,7 +815,7 @@ } ] }, - "description": "Image to display for the `Avatar`.\nIt accepts a standard React Native Image `source` prop\nOr a function that returns an `Image`." + "description": "Image to display for the `Avatar`.\nIt accepts a standard React Native Image `source` prop\nor a function that returns an image component.\nFunction sources receive `{ size, style, onError }` matching the host avatar.\nApply `style` so the image fills the circle, and call `onError` to trigger `fallback`." }, "size": { "required": false, @@ -824,10 +824,46 @@ }, "description": "Size of the avatar.", "defaultValue": { - "value": "64", - "computed": false + "value": "DEFAULT_SIZE", + "computed": true } }, + "fallback": { + "required": false, + "tsType": { + "name": "signature", + "type": "function", + "raw": "(props: { size: number }) => React.ReactNode", + "signature": { + "arguments": [ + { + "name": "props", + "type": { + "name": "signature", + "type": "object", + "raw": "{ size: number }", + "signature": { + "properties": [ + { + "key": "size", + "value": { + "name": "number", + "required": true + } + } + ] + } + } + } + ], + "return": { + "name": "ReactReactNode", + "raw": "React.ReactNode" + } + } + }, + "description": "Content shown when the image fails to load.\nReceives host `size` so custom content can match the avatar." + }, "style": { "required": false, "tsType": { @@ -920,7 +956,7 @@ "tsType": { "name": "string" }, - "description": "Initials to show as the text in the `Avatar`." + "description": "Initials to show as the text in the `Avatar`.\nTruncated to the first two graphemes (emoji, ZWJ sequences and combining\nmarks stay intact)." }, "size": { "required": false, @@ -929,8 +965,8 @@ }, "description": "Size of the avatar.", "defaultValue": { - "value": "64", - "computed": false + "value": "DEFAULT_SIZE", + "computed": true } }, "color": { @@ -938,7 +974,7 @@ "tsType": { "name": "string" }, - "description": "Custom color for the text." + "description": "Custom color for the text. Takes precedence over the automatic contrast\ncolor below." }, "style": { "required": false, @@ -951,7 +987,7 @@ ], "raw": "StyleProp" }, - "description": "Style for text container" + "description": "Style for text container. A custom `backgroundColor` is automatically\npaired with a contrasting text color when `color` is not set: string\nvalues use a luminance heuristic, while opaque/dynamic values\n(`PlatformColor` / `DynamicColorIOS`) are paired with a theme role's\n`on-` color, falling back to `onSurface`." }, "labelStyle": { "required": false, diff --git a/src/components/Avatar/AvatarIcon.tsx b/src/components/Avatar/AvatarIcon.tsx index 0a28081a10..9df9c00db1 100644 --- a/src/components/Avatar/AvatarIcon.tsx +++ b/src/components/Avatar/AvatarIcon.tsx @@ -18,9 +18,17 @@ export type Props = ViewProps & { */ size?: number; /** - * Custom color for the icon. + * Custom color for the icon. Takes precedence over the automatic contrast + * color below. */ color?: string; + /** + * Style for the icon container. A custom `backgroundColor` is + * automatically paired with a contrasting icon color when `color` is not + * set: string values use a luminance heuristic, while opaque/dynamic + * values (`PlatformColor` / `DynamicColorIOS`) are paired with a theme + * role's `on-` color, falling back to `onSurface`. + */ style?: StyleProp; /** * @optional diff --git a/src/components/Avatar/AvatarImage.tsx b/src/components/Avatar/AvatarImage.tsx index 3e0a21b884..a1a89c8482 100644 --- a/src/components/Avatar/AvatarImage.tsx +++ b/src/components/Avatar/AvatarImage.tsx @@ -33,7 +33,9 @@ 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 }` matching the host avatar. + * Apply `style` so the image fills the circle, and call `onError` to trigger `fallback`. */ source: AvatarImageSource; /** @@ -89,6 +91,26 @@ export type Props = ViewProps & { * ); * export default MyComponent * ``` + * + * Show another avatar when the image fails to load: + * ```js + * } + * /> + * ``` + * + * Custom image components should apply the host `style` and `onError`: + * ```js + * ( + * + * )} + * fallback={({ size }) => } + * /> + * ``` */ const AvatarImage = ({ size = DEFAULT_SIZE, diff --git a/src/components/Avatar/AvatarText.tsx b/src/components/Avatar/AvatarText.tsx index b922cc7a7e..47b30c655f 100644 --- a/src/components/Avatar/AvatarText.tsx +++ b/src/components/Avatar/AvatarText.tsx @@ -18,11 +18,16 @@ export type Props = ViewProps & { */ size?: number; /** - * Custom color for the text. + * Custom color for the text. Takes precedence over the automatic contrast + * color below. */ color?: string; /** - * Style for text container + * Style for text container. A custom `backgroundColor` is automatically + * paired with a contrasting text color when `color` is not set: string + * values use a luminance heuristic, while opaque/dynamic values + * (`PlatformColor` / `DynamicColorIOS`) are paired with a theme role's + * `on-` color, falling back to `onSurface`. */ style?: StyleProp; /** From 64c33a88d3bcca0bb55587e04dca70f54e096527 Mon Sep 17 00:00:00 2001 From: Oleksandr Zavarzin Date: Fri, 21 Aug 2026 16:50:21 +0200 Subject: [PATCH 8/8] refactor: move avatar accessibility props to use alt --- .../docs/components/Avatar/AvatarImage.mdx | 21 ++++- docs/src/data/componentDocs6x.json | 15 +++- src/components/Avatar/AvatarIcon.tsx | 3 + src/components/Avatar/AvatarImage.tsx | 53 ++++++++---- src/components/Avatar/AvatarText.tsx | 7 +- src/components/Avatar/utils.ts | 2 +- src/components/__tests__/Avatar.test.tsx | 84 ++++++++++++------- .../__snapshots__/Avatar.test.tsx.snap | 20 +++-- .../__tests__/splitAccessibilityProps.test.ts | 44 ---------- src/utils/splitAccessibilityProps.ts | 82 ------------------ 10 files changed, 139 insertions(+), 192 deletions(-) delete mode 100644 src/utils/__tests__/splitAccessibilityProps.test.ts delete mode 100644 src/utils/splitAccessibilityProps.ts diff --git a/docs/6.x/docs/components/Avatar/AvatarImage.mdx b/docs/6.x/docs/components/Avatar/AvatarImage.mdx index 4bc65699b4..849ff518d1 100644 --- a/docs/6.x/docs/components/Avatar/AvatarImage.mdx +++ b/docs/6.x/docs/components/Avatar/AvatarImage.mdx @@ -22,26 +22,31 @@ import * as React from 'react'; import { Avatar } from 'react-native-paper'; const MyComponent = () => ( - + ); export default MyComponent ``` +Pass `alt` to describe the image to assistive technology. Avatars without it +are treated as decorative and skipped by screen readers. + Show another avatar when the image fails to load: ```js } /> ``` -Custom image components should apply the host `style` and `onError`: +Custom image components should apply the host `style`, `onError` and `alt`: ```js ( - + alt="Jane Doe" + source={({ style, onError, alt }) => ( + )} fallback={({ size }) => } /> @@ -71,6 +76,14 @@ Custom image components should apply the host `style` and `onError`:
+### alt + +
+ + + +
+ ### fallback
diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json index d9f97d9a4c..2eda6cfe75 100644 --- a/docs/src/data/componentDocs6x.json +++ b/docs/src/data/componentDocs6x.json @@ -793,10 +793,10 @@ "Avatar/AvatarImage": { "filepath": "Avatar/AvatarImage.tsx", "title": "Avatar.Image", - "description": "Avatars can be used to represent people in a graphical way.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Avatar } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n);\nexport default MyComponent\n```\n\nShow another avatar when the image fails to load:\n```js\n }\n/>\n```\n\nCustom image components should apply the host `style` and `onError`:\n```js\n (\n \n )}\n fallback={({ size }) => }\n/>\n```", + "description": "Avatars can be used to represent people in a graphical way.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Avatar } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n);\nexport default MyComponent\n```\n\nPass `alt` to describe the image to assistive technology. Avatars without it\nare treated as decorative and skipped by screen readers.\n\nShow another avatar when the image fails to load:\n```js\n }\n/>\n```\n\nCustom image components should apply the host `style`, `onError` and `alt`:\n```js\n (\n \n )}\n fallback={({ size }) => }\n/>\n```", "link": "avatar-image", "data": { - "description": "Avatars can be used to represent people in a graphical way.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Avatar } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n);\nexport default MyComponent\n```\n\nShow another avatar when the image fails to load:\n```js\n }\n/>\n```\n\nCustom image components should apply the host `style` and `onError`:\n```js\n (\n \n )}\n fallback={({ size }) => }\n/>\n```", + "description": "Avatars can be used to represent people in a graphical way.\n\n## Usage\n```js\nimport * as React from 'react';\nimport { Avatar } from 'react-native-paper';\n\nconst MyComponent = () => (\n \n);\nexport default MyComponent\n```\n\nPass `alt` to describe the image to assistive technology. Avatars without it\nare treated as decorative and skipped by screen readers.\n\nShow another avatar when the image fails to load:\n```js\n }\n/>\n```\n\nCustom image components should apply the host `style`, `onError` and `alt`:\n```js\n (\n \n )}\n fallback={({ size }) => }\n/>\n```", "displayName": "Avatar.Image", "methods": [], "statics": [], @@ -815,7 +815,7 @@ } ] }, - "description": "Image to display for the `Avatar`.\nIt accepts a standard React Native Image `source` prop\nor a function that returns an image component.\nFunction sources receive `{ size, style, onError }` matching the host avatar.\nApply `style` so the image fills the circle, and call `onError` to trigger `fallback`." + "description": "Image to display for the `Avatar`.\nIt accepts a standard React Native Image `source` prop\nor a function that returns an image component.\nFunction sources receive `{ size, style, onError, alt }` matching the host avatar.\nApply `style` so the image fills the circle, pass `alt` on for assistive\ntechnology, and call `onError` to trigger `fallback`." }, "size": { "required": false, @@ -828,6 +828,13 @@ "computed": true } }, + "alt": { + "required": false, + "tsType": { + "name": "string" + }, + "description": "Text describing the image for assistive technology." + }, "fallback": { "required": false, "tsType": { @@ -956,7 +963,7 @@ "tsType": { "name": "string" }, - "description": "Initials to show as the text in the `Avatar`.\nTruncated to the first two graphemes (emoji, ZWJ sequences and combining\nmarks stay intact)." + "description": "Initials to show as the text in the `Avatar`." }, "size": { "required": false, diff --git a/src/components/Avatar/AvatarIcon.tsx b/src/components/Avatar/AvatarIcon.tsx index 9df9c00db1..31fedb691d 100644 --- a/src/components/Avatar/AvatarIcon.tsx +++ b/src/components/Avatar/AvatarIcon.tsx @@ -64,6 +64,8 @@ const Avatar = ({ backgroundColor, color: customColor, }); + const hasLabel = + rest.accessibilityLabel !== undefined || rest['aria-label'] !== undefined; return ( diff --git a/src/components/Avatar/AvatarImage.tsx b/src/components/Avatar/AvatarImage.tsx index a1a89c8482..3cc80e5657 100644 --- a/src/components/Avatar/AvatarImage.tsx +++ b/src/components/Avatar/AvatarImage.tsx @@ -1,7 +1,6 @@ import * as React from 'react'; import { Image, StyleSheet, View } from 'react-native'; import type { - AccessibilityProps, ImageProps, ImageSourcePropType, StyleProp, @@ -17,13 +16,21 @@ import { import { useInternalTheme } from '../../core/theming'; import { cornerFull } from '../../theme/tokens/sys/shape'; import type { ThemeProp } from '../../types'; -import { splitAccessibilityProps } from '../../utils/splitAccessibilityProps'; export type AvatarImageSourceProps = { size: number; style: { width: number; height: number; borderRadius: number }; onError?: ImageProps['onError']; -} & AccessibilityProps; + /** + * Present when the host received an `alt`. Pass it to your image so it is + * announced by assistive technology. + */ + alt?: string; + /** + * `false` when the host received no `alt`, marking the image as decorative. + */ + accessible?: boolean; +}; export type AvatarImageSource = | ImageSourcePropType @@ -34,14 +41,19 @@ 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 component. - * Function sources receive `{ size, style, onError }` matching the host avatar. - * Apply `style` so the image fills the circle, and call `onError` to trigger `fallback`. + * Function sources receive `{ size, style, onError, alt }` matching the host avatar. + * Apply `style` so the image fills the circle, pass `alt` on for assistive + * technology, and call `onError` to trigger `fallback`. */ source: AvatarImageSource; /** * Size of the avatar. */ size?: number; + /** + * Text describing the image for assistive technology. + */ + alt?: string; /** * Content shown when the image fails to load. * Receives host `size` so custom content can match the avatar. @@ -87,26 +99,31 @@ export type Props = ViewProps & { * import { Avatar } from 'react-native-paper'; * * const MyComponent = () => ( - * + * * ); * export default MyComponent * ``` * + * Pass `alt` to describe the image to assistive technology. Avatars without it + * are treated as decorative and skipped by screen readers. + * * Show another avatar when the image fails to load: * ```js * } * /> * ``` * - * Custom image components should apply the host `style` and `onError`: + * Custom image components should apply the host `style`, `onError` and `alt`: * ```js * ( - * + * alt="Jane Doe" + * source={({ style, onError, alt }) => ( + * * )} * fallback={({ size }) => } * /> @@ -116,6 +133,7 @@ const AvatarImage = ({ size = DEFAULT_SIZE, source, fallback, + alt, style, onError, onLayout, @@ -130,16 +148,13 @@ const AvatarImage = ({ const theme = useInternalTheme(themeOverrides); const { backgroundColor } = StyleSheet.flatten(style) || {}; const { background } = resolveAvatarColors({ theme, backgroundColor }); - const { accessibilityProps, rest: viewProps } = splitAccessibilityProps(rest); const imageStyle = { width: size, height: size, borderRadius: cornerFull, }; const imageA11y = - Object.keys(accessibilityProps).length > 0 - ? accessibilityProps - : { accessible: false as const }; + alt === undefined ? { accessible: false as const } : { alt }; const sourceKey = getAvatarImageSourceKey(source); const previousSourceKey = React.useRef(sourceKey); const [hasError, setHasError] = React.useState(false); @@ -158,6 +173,12 @@ const AvatarImage = ({ const showImage = !(hasError && fallback !== undefined); + const hostA11y = showImage + ? { accessible: false, importantForAccessibility: 'no' as const } + : alt !== undefined + ? { accessible: true, 'aria-label': alt } + : {}; + return ( {showImage && typeof source === 'function' ? source({ diff --git a/src/components/Avatar/AvatarText.tsx b/src/components/Avatar/AvatarText.tsx index 47b30c655f..c7dfb8bcd7 100644 --- a/src/components/Avatar/AvatarText.tsx +++ b/src/components/Avatar/AvatarText.tsx @@ -76,6 +76,8 @@ const AvatarText = ({ }); const { fontScale } = useWindowDimensions(); const avatarInitials = takeGraphemes(label, 2); + const hasCustomLabel = + rest.accessibilityLabel !== undefined || rest['aria-label'] !== undefined; return ( {avatarInitials} diff --git a/src/components/Avatar/utils.ts b/src/components/Avatar/utils.ts index 359264514a..3142f6b13f 100644 --- a/src/components/Avatar/utils.ts +++ b/src/components/Avatar/utils.ts @@ -71,7 +71,7 @@ export const getAvatarImageSourceKey = (source: unknown) => { !Array.isArray(source) && 'uri' in source ) { - return (source as { uri: unknown }).uri; + return source.uri; } return source; diff --git a/src/components/__tests__/Avatar.test.tsx b/src/components/__tests__/Avatar.test.tsx index f2b606bf01..68e10f55a0 100644 --- a/src/components/__tests__/Avatar.test.tsx +++ b/src/components/__tests__/Avatar.test.tsx @@ -172,16 +172,15 @@ describe('AvatarImage listener', () => { }); }); -it('forwards accessibility props to the image, not the wrapper', async () => { +// React Native turns `alt` into `accessible` plus a label inside the platform +// `Image`, so asserting it reaches the image is enough here. +it('labels the image with alt, not the wrapper', async () => { const tree = ( await render( ) ).toJSON(); @@ -194,17 +193,14 @@ it('forwards accessibility props to the image, not the wrapper', async () => { children: [ { props: { - accessibilityLabel: 'Profile photo', - accessibilityHint: 'User avatar', - accessibilityRole: 'image', - 'aria-label': 'Jane Doe', + alt: 'Jane Doe', }, }, ], }); expect(tree).not.toMatchObject({ props: { - accessibilityLabel: 'Profile photo', + 'aria-label': 'Jane Doe', }, }); }); @@ -242,20 +238,41 @@ it('hides text avatar initials from assistive tech', async () => { expect(tree).toMatchObject({ props: { + accessible: true, accessibilityLabel: 'Jane Doe', accessibilityRole: 'image', }, children: [ { props: { - accessibilityElementsHidden: true, - importantForAccessibility: 'no-hide-descendants', + 'aria-hidden': true, }, }, ], }); }); +it('makes a labelled icon avatar an accessibility element', async () => { + const tree = ( + await render() + ).toJSON(); + + // `View` is not an accessibility element by default, so without `accessible` + // the label is never announced on iOS. + expect(tree).toMatchObject({ + props: { + accessible: true, + accessibilityLabel: 'Folder', + }, + }); +}); + +it('leaves an unlabelled icon avatar decorative', async () => { + const tree = (await render()).toJSON(); + + expect(tree).not.toMatchObject({ props: { accessible: true } }); +}); + it('bounds text avatar initials by grapheme', async () => { const tree = (await render()).toJSON(); @@ -392,7 +409,7 @@ describe('AvatarImage fallback', () => { expect(screen.getByTestId('custom-image')).toBeTruthy(); }); - it('forwards accessibility props to a function source', async () => { + it('passes alt to a function source', async () => { const source = jest.fn( ({ style, @@ -411,20 +428,13 @@ describe('AvatarImage fallback', () => { ) ); - await render( - - ); + await render(); expect(source).toHaveBeenCalledWith( - expect.objectContaining({ - accessibilityLabel: 'Profile photo', - accessibilityRole: 'image', - }) + expect.objectContaining({ alt: 'Jane Doe' }) + ); + expect(source).not.toHaveBeenCalledWith( + expect.objectContaining({ accessible: false }) ); }); @@ -487,14 +497,13 @@ describe('AvatarImage fallback', () => { expect(screen.queryByTestId('custom-image')).toBeNull(); }); - it('forwards accessibility props to the host when fallback is shown', async () => { + it('moves alt onto the host when fallback is shown', async () => { const { toJSON } = await render( } - accessibilityLabel="Profile photo" - accessibilityRole="image" + alt="Jane Doe" /> ); @@ -502,9 +511,24 @@ describe('AvatarImage fallback', () => { expect(toJSON()).toMatchObject({ props: { - accessibilityLabel: 'Profile photo', - accessibilityRole: 'image', + accessible: true, + 'aria-label': 'Jane Doe', }, }); }); + + it('leaves the host unlabelled when a fallback is shown without alt', async () => { + const { toJSON } = await render( + } + /> + ); + + await fireEvent(screen.getByTestId('avatar-image'), 'onError'); + + // The fallback carries its own label, so the host must not swallow it. + expect(toJSON()).not.toMatchObject({ props: { accessible: true } }); + }); }); diff --git a/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap b/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap index da4d4c4726..d3707aa2de 100644 --- a/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap +++ b/src/components/__tests__/__snapshots__/Avatar.test.tsx.snap @@ -141,6 +141,8 @@ exports[`renders avatar with image 1`] = ` exports[`renders avatar with text 1`] = ` { - it('moves accessibility props out of rest', () => { - const onAccessibilityAction = () => {}; - const { accessibilityProps, rest } = splitAccessibilityProps({ - accessibilityLabel: 'Profile photo', - accessibilityHint: 'User avatar', - accessibilityRole: 'image', - 'aria-label': 'Jane Doe', - role: 'img', - onAccessibilityAction, - pointerEvents: 'none', - collapsable: false, - }); - - expect(accessibilityProps).toEqual({ - accessibilityLabel: 'Profile photo', - accessibilityHint: 'User avatar', - accessibilityRole: 'image', - 'aria-label': 'Jane Doe', - role: 'img', - onAccessibilityAction, - }); - expect(rest).toEqual({ - pointerEvents: 'none', - collapsable: false, - }); - }); - - it('omits undefined accessibility values', () => { - const { accessibilityProps, rest } = splitAccessibilityProps({ - accessibilityLabel: undefined, - pointerEvents: 'box-none', - }); - - expect(accessibilityProps).toEqual({}); - expect(rest).toEqual({ - pointerEvents: 'box-none', - }); - }); -}); diff --git a/src/utils/splitAccessibilityProps.ts b/src/utils/splitAccessibilityProps.ts deleted file mode 100644 index 19d8c9ba61..0000000000 --- a/src/utils/splitAccessibilityProps.ts +++ /dev/null @@ -1,82 +0,0 @@ -import type { AccessibilityProps } from 'react-native'; - -/** - * Accessibility props that are present on the `AccessibilityProps` interface. - */ -const ACCESSIBILITY_PROP_PRESENCE = { - accessible: true, - accessibilityActions: true, - accessibilityLabel: true, - 'aria-label': true, - accessibilityRole: true, - accessibilityState: true, - 'aria-busy': true, - 'aria-checked': true, - 'aria-disabled': true, - 'aria-expanded': true, - 'aria-selected': true, - accessibilityHint: true, - accessibilityValue: true, - 'aria-valuemax': true, - 'aria-valuemin': true, - 'aria-valuenow': true, - 'aria-valuetext': true, - onAccessibilityAction: true, - importantForAccessibility: true, - 'aria-hidden': true, - 'aria-modal': true, - role: true, - accessibilityLabelledBy: true, - 'aria-labelledby': true, - accessibilityLiveRegion: true, - 'aria-live': true, - screenReaderFocusable: true, - accessibilityElementsHidden: true, - accessibilityViewIsModal: true, - onAccessibilityEscape: true, - onAccessibilityTap: true, - onMagicTap: true, - accessibilityIgnoresInvertColors: true, - accessibilityLanguage: true, - accessibilityShowsLargeContentViewer: true, - accessibilityLargeContentTitle: true, - accessibilityRespondsToUserInteraction: true, -} satisfies Record; - -/** - * Keys of the `AccessibilityProps` interface. - */ -const ACCESSIBILITY_PROP_KEYS = Object.keys( - ACCESSIBILITY_PROP_PRESENCE -) as (keyof AccessibilityProps)[]; - -/** - * Splits the accessibility props from the rest of the props. - * @param props - The props to split. - * @returns The accessibility props and the rest of the props. - */ -export function splitAccessibilityProps( - props: T -) { - const accessibilityProps: AccessibilityProps = {}; - const rest = { ...props }; - - for (const key of ACCESSIBILITY_PROP_KEYS) { - if (!Object.hasOwn(rest, key)) { - continue; - } - - const value = rest[key]; - if (value !== undefined) { - (accessibilityProps as Record)[key] = - value; - } - - delete rest[key]; - } - - return { - accessibilityProps, - rest: rest as Omit, - }; -}