diff --git a/docs/6.x/docs/components/Avatar/AvatarImage.mdx b/docs/6.x/docs/components/Avatar/AvatarImage.mdx
index ecd4bcc751..849ff518d1 100644
--- a/docs/6.x/docs/components/Avatar/AvatarImage.mdx
+++ b/docs/6.x/docs/components/Avatar/AvatarImage.mdx
@@ -22,11 +22,36 @@ 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`, `onError` and `alt`:
+```js
+ (
+
+ )}
+ fallback={({ size }) => }
+/>
+```
+
## Props
@@ -51,6 +76,22 @@ export default MyComponent
+### alt
+
+
+
+
+
+
+
+### fallback
+
+
+
+
+
+
+
### style
diff --git a/docs/src/data/componentDocs6x.json b/docs/src/data/componentDocs6x.json
index 56b356912b..2eda6cfe75 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\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```",
+ "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": [],
@@ -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, 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,
@@ -824,10 +824,53 @@
},
"description": "Size of the avatar.",
"defaultValue": {
- "value": "64",
- "computed": false
+ "value": "DEFAULT_SIZE",
+ "computed": true
}
},
+ "alt": {
+ "required": false,
+ "tsType": {
+ "name": "string"
+ },
+ "description": "Text describing the image for assistive technology."
+ },
+ "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": {
@@ -929,8 +972,8 @@
},
"description": "Size of the avatar.",
"defaultValue": {
- "value": "64",
- "computed": false
+ "value": "DEFAULT_SIZE",
+ "computed": true
}
},
"color": {
@@ -938,7 +981,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 +994,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 9c3b813e59..31fedb691d 100644
--- a/src/components/Avatar/AvatarIcon.tsx
+++ b/src/components/Avatar/AvatarIcon.tsx
@@ -1,15 +1,13 @@
import { StyleSheet, View } from 'react-native';
import type { StyleProp, ViewProps, ViewStyle } from 'react-native';
+import { DEFAULT_SIZE, ICON_SIZE_RATIO, resolveAvatarColors } from './utils';
import { useInternalTheme } from '../../core/theming';
-import { white } from '../../theme/colors';
+import { cornerFull } from '../../theme/tokens/sys/shape';
import type { ThemeProp } from '../../types';
-import getContrastingColor from '../../utils/getContrastingColor';
import Icon from '../Icon';
import type { IconSource } from '../Icon';
-const defaultSize = 64;
-
export type Props = ViewProps & {
/**
* Icon to display for the `Avatar`.
@@ -20,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
@@ -45,17 +51,21 @@ export type Props = ViewProps & {
*/
const Avatar = ({
icon,
- size = defaultSize,
+ size = DEFAULT_SIZE,
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,
+ });
+ 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 51330dd70c..3cc80e5657 100644
--- a/src/components/Avatar/AvatarImage.tsx
+++ b/src/components/Avatar/AvatarImage.tsx
@@ -8,26 +8,57 @@ import type {
ViewStyle,
} from 'react-native';
+import {
+ DEFAULT_SIZE,
+ getAvatarImageSourceKey,
+ resolveAvatarColors,
+} from './utils';
import { useInternalTheme } from '../../core/theming';
+import { cornerFull } from '../../theme/tokens/sys/shape';
import type { ThemeProp } from '../../types';
-const defaultSize = 64;
+export type AvatarImageSourceProps = {
+ size: number;
+ style: { width: number; height: number; borderRadius: number };
+ onError?: ImageProps['onError'];
+ /**
+ * 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
- | ((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 }` 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.
+ */
+ fallback?: (props: { size: number }) => React.ReactNode;
style?: StyleProp;
/**
* Invoked on load error.
@@ -68,14 +99,41 @@ 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`, `onError` and `alt`:
+ * ```js
+ * (
+ *
+ * )}
+ * fallback={({ size }) => }
+ * />
+ * ```
*/
const AvatarImage = ({
- size = defaultSize,
+ size = DEFAULT_SIZE,
source,
+ fallback,
+ alt,
style,
onError,
onLayout,
@@ -87,8 +145,39 @@ 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 imageA11y =
+ alt === undefined ? { accessible: false as const } : { alt };
+ const sourceKey = getAvatarImageSourceKey(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);
+
+ const hostA11y = showImage
+ ? { accessible: false, importantForAccessibility: 'no' as const }
+ : alt !== undefined
+ ? { accessible: true, 'aria-label': alt }
+ : {};
return (
- {typeof source === 'function' && source({ size })}
- {typeof source !== 'function' && (
+ {showImage && typeof source === 'function'
+ ? source({
+ size,
+ style: imageStyle,
+ onError: handleError,
+ ...imageA11y,
+ })
+ : 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/Avatar/AvatarText.tsx b/src/components/Avatar/AvatarText.tsx
index d9e16c8090..c7dfb8bcd7 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 { DEFAULT_SIZE, resolveAvatarColors } from './utils';
import { useInternalTheme } from '../../core/theming';
-import { white } from '../../theme/colors';
+import { cornerFull } from '../../theme/tokens/sys/shape';
import type { ThemeProp } from '../../types';
-import getContrastingColor from '../../utils/getContrastingColor';
+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`.
@@ -19,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;
/**
@@ -55,7 +59,7 @@ export type Props = ViewProps & {
*/
const AvatarText = ({
label,
- size = defaultSize,
+ size = DEFAULT_SIZE,
style,
labelStyle,
color: customColor,
@@ -64,12 +68,16 @@ const AvatarText = ({
...rest
}: Props) => {
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);
+ const hasCustomLabel =
+ rest.accessibilityLabel !== undefined || rest['aria-label'] !== undefined;
return (
- {label}
+ {avatarInitials}
);
diff --git a/src/components/Avatar/utils.ts b/src/components/Avatar/utils.ts
new file mode 100644
index 0000000000..3142f6b13f
--- /dev/null
+++ b/src/components/Avatar/utils.ts
@@ -0,0 +1,78 @@
+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 const DEFAULT_SIZE = 64;
+export const ICON_SIZE_RATIO = 0.6;
+
+export type ResolvedAvatarColors = {
+ background: ColorValue;
+ textColor: ColorValue;
+};
+
+/**
+ * Resolve background and content colors for an avatar.
+ *
+ * - No custom background → MD3 container pair (primaryContainer /
+ * onPrimaryContainer).
+ * - Explicit `color` wins.
+ * - String custom backgrounds keep the luminance heuristic (arbitrary
+ * per-user colors have no on- role).
+ * - Opaque custom 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 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,
+ textColor: getContrastingColor(background, white, 'rgba(0, 0, 0, .54)'),
+ };
+ }
+
+ 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.uri;
+ }
+
+ return source;
+};
diff --git a/src/components/__tests__/Avatar.test.tsx b/src/components/__tests__/Avatar.test.tsx
index dc437b2dd9..68e10f55a0 100644
--- a/src/components/__tests__/Avatar.test.tsx
+++ b/src/components/__tests__/Avatar.test.tsx
@@ -1,12 +1,15 @@
-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';
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 };
+
const styles = StyleSheet.create({
bgColor: {
backgroundColor: red500,
@@ -168,3 +171,364 @@ describe('AvatarImage listener', () => {
expect(onListenerMock).toHaveBeenCalled();
});
});
+
+// 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();
+
+ expect(tree).toMatchObject({
+ props: {
+ accessible: false,
+ importantForAccessibility: 'no',
+ },
+ children: [
+ {
+ props: {
+ alt: 'Jane Doe',
+ },
+ },
+ ],
+ });
+ expect(tree).not.toMatchObject({
+ props: {
+ 'aria-label': 'Jane Doe',
+ },
+ });
+});
+
+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: {
+ accessible: true,
+ accessibilityLabel: 'Jane Doe',
+ accessibilityRole: 'image',
+ },
+ children: [
+ {
+ props: {
+ '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();
+
+ expect(tree).toMatchObject({
+ children: [
+ {
+ children: ['👨👩👧X'],
+ },
+ ],
+ });
+});
+
+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', hidden)).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', hidden)).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', hidden)).toBeTruthy();
+
+ await rerender(
+ }
+ />
+ );
+
+ expect(screen.getByTestId('avatar-image')).toBeTruthy();
+ expect(screen.queryByText('JD', hidden)).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', hidden)).toBeTruthy();
+ });
+
+ it('passes host size, style, and a11y 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: cornerFull },
+ onError: expect.any(Function),
+ accessible: false,
+ });
+ expect(screen.getByTestId('custom-image')).toBeTruthy();
+ });
+
+ it('passes alt 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({ alt: 'Jane Doe' })
+ );
+ expect(source).not.toHaveBeenCalledWith(
+ expect.objectContaining({ accessible: false })
+ );
+ });
+
+ 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', 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();
+ });
+
+ it('moves alt onto the host when fallback is shown', async () => {
+ const { toJSON } = await render(
+ }
+ alt="Jane Doe"
+ />
+ );
+
+ await fireEvent(screen.getByTestId('avatar-image'), 'onError');
+
+ expect(toJSON()).toMatchObject({
+ props: {
+ 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__/AvatarUtils.test.tsx b/src/components/__tests__/AvatarUtils.test.tsx
new file mode 100644
index 0000000000..b41e6d97c9
--- /dev/null
+++ b/src/components/__tests__/AvatarUtils.test.tsx
@@ -0,0 +1,118 @@
+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, getAvatarImageSourceKey } from '../Avatar/utils';
+
+const withPlatformColor = (
+ theme: InternalTheme,
+ role: 'primary' | 'primaryContainer' | 'error',
+ resource: string
+): InternalTheme => ({
+ ...theme,
+ colors: {
+ ...theme.colors,
+ [role]: { resource_paths: [resource] } as unknown as ColorValue,
+ },
+});
+
+describe('resolveAvatarColors', () => {
+ it('uses the MD3 container pair for the default background', () => {
+ const theme = getTheme();
+ expect(typeof theme.colors.primaryContainer).toBe('string');
+ expect(resolveAvatarColors({ theme })).toEqual({
+ background: theme.colors.primaryContainer,
+ textColor: theme.colors.onPrimaryContainer,
+ });
+ });
+
+ it('uses onPrimaryContainer for an opaque default container token', () => {
+ const theme = withPlatformColor(
+ getTheme(),
+ 'primaryContainer',
+ '@android:color/system_primary_container_light'
+ );
+ expect(resolveAvatarColors({ theme })).toEqual({
+ background: theme.colors.primaryContainer,
+ textColor: theme.colors.onPrimaryContainer,
+ });
+ });
+
+ 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',
+ });
+ });
+});
+
+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 9e55666cef..d3707aa2de 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,
},
@@ -102,20 +102,27 @@ exports[`renders avatar with icon and custom background color 1`] = `
exports[`renders avatar with image 1`] = `
{
+ 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/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;
}
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('');
+}