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(''); +}