Fix/define avatar identity and fallback semantics - #5058
Conversation
| accessibilityElementsHidden | ||
| importantForAccessibility="no-hide-descendants" |
There was a problem hiding this comment.
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"
This hides the initials from assistive tech unconditionally, so <Avatar.Text label="JD" /> with no other props goes from announcing "JD" to announcing nothing. On iOS RN Text is an accessibility element unless accessible={false} (Libraries/Text/Text.js:145-148), and accessibilityElementsHidden removes it; on Android no-hide-descendants does the same. Nothing takes over, because View.accessible defaults to false in Fabric (ReactCommon/.../AccessibilityProps.h:31), so a host aria-label alone isn't announced either.
Put the name on the host and hide the glyphs, and use the aria form — aria-hidden maps to exactly these two props (Text.js:138-141):
]}
+ accessible
+ aria-label={label}
{...rest}
> numberOfLines={1}
maxFontSizeMultiplier={maxFontSizeMultiplier}
- accessibilityElementsHidden
- importantForAccessibility="no-hide-descendants"
+ aria-hidden{...rest} still lets a consumer override with aria-label (RN prefers aria-label over accessibilityLabel when both are set).
| const imageA11y = | ||
| Object.keys(accessibilityProps).length > 0 | ||
| ? accessibilityProps | ||
| : { accessible: false as const }; |
There was a problem hiding this comment.
const imageA11y =
Object.keys(accessibilityProps).length > 0
? accessibilityProps
: { accessible: false as const };
Moving the label to the Image doesn't make it announceable on iOS. RN's Image sets accessible only from alt or an explicit accessible — Image.ios.js:170-171 is props.alt !== undefined ? true : props.accessible, Image.android.js:291-295 the same — so <Avatar.Image aria-label="Jane" /> yields an Image with a label and isAccessibilityElement = NO, while the wrapper is now explicitly accessible={false}. Android still works (contentDescription makes the view focusable), iOS is silent. Same gap at :176: the fallback branch spreads accessibilityProps onto the wrapper View without accessible.
- {...(showImage
- ? { accessible: false, importantForAccessibility: 'no' as const }
- : accessibilityProps)}
+ {...(showImage
+ ? { accessible: false, importantForAccessibility: 'no' as const }
+ : { accessible: true, ...accessibilityProps })}and give the image accessible whenever a label came in. alt is the cleaner route — it sets both halves on both platforms — and would let Avatar.Image declare one documented prop instead of forwarding 40.
| */ | ||
| const ACCESSIBILITY_PROP_KEYS = Object.keys( | ||
| ACCESSIBILITY_PROP_PRESENCE | ||
| ) as (keyof AccessibilityProps)[]; |
There was a problem hiding this comment.
return (source as { uri: unknown }).uri;
Four type assertions. src/ on main has none outside as const, and @satya164 has asked for them removed verbatim in #4998 and #4994. This one is unnecessary — TS narrows 'uri' in source on an object since 4.9:
- return (source as { uri: unknown }).uri;
+ return source.uri;|
|
||
| const value = rest[key]; | ||
| if (value !== undefined) { | ||
| (accessibilityProps as Record<keyof AccessibilityProps, unknown>)[key] = |
|
|
||
| return { | ||
| accessibilityProps, | ||
| rest: rest as Omit<T, keyof AccessibilityProps>, |
Motivation
Defines Avatar identity, contrast, and fallback semantics as a Paper extension (not a standalone MD3 family). Reuses shared theme tokens where they match, and fixes accessibility, initials, and image-error handling rather than chasing a spec that does not exist for Avatar.
Builds on the MD3 token work in #5016 (container pair,
cornerFull,titleMedium) and finishes the contrast / a11y / fallback gaps that PR left open.Changes
Avatar (AvatarIcon, AvatarText, AvatarImage)
PlatformColor,DynamicColorIOS) go throughcontentColorFor— a theme-role token pairs with itson-color; anything else falls back toonSurface. Explicitcolorstill wins.getContrastingColoris string-only, so it no longer guesses white for unresolved dynamic colors.Avatar.Image): a11y props land on the renderedImage(or function source), not the wrapperView. An unlabeled / non-actionable avatar stays unfocusable (accessible={false}). Whenfallbackis showing, a11y props move to the host so the replacement content is announced.Avatar.Text): initials are hidden from assistive tech (accessibilityElementsHidden/importantForAccessibility="no-hide-descendants") so a parent label is not double-read.takeGraphemes(label, 2)— combining marks, emoji (ZWJ / skin tones), and flag sequences stay intact; long names are bounded to two graphemes.fallback={({ size }) => …}when the image fails. Functionsourcenow receives{ size, style, onError, …a11y }so custom images fill the circle and can trigger fallback. Source identity is keyed by URI (or'function') so inline renderers and equal-URI objects do not reset error state; a real URI change retries.fallback+ hostsize/styleguidanceVisual / behavioral changes
PlatformColor/ dynamic backgrounds no longer get white content by default.Avatar.Textshows at most two graphemes (emoji-safe); previously sliced by UTF-16 code unit.fallbackinstead of staying empty/broken.Public API
Avatar.Imagefallback?: (props: { size: number }) => React.ReactNodesourceprops now includestyleandonError(plus a11y). Existing{ size }usage still works.Related issue
(new — to be created) · overlaps #5016 / #4990
Test plan
yarn typescriptyarn lintyarn testyarn example android):PlatformColorstays legible (not forced white); long names / emoji / combining marks show as two graphemesfallback; functionsourcefills the circle via hoststyle; new URI retries, same URI keeps fallbackScreenshots:
Jane Doe), combining mark (éva), family emoji; content truncated to 2 graphemes