Skip to content

Fix/define avatar identity and fallback semantics - #5058

Open
oleksandrzavarzin-callstack wants to merge 8 commits into
callstack:mainfrom
oleksandrzavarzin-callstack:fix/define-avatar-identity-and-fallback-semantics
Open

Fix/define avatar identity and fallback semantics#5058
oleksandrzavarzin-callstack wants to merge 8 commits into
callstack:mainfrom
oleksandrzavarzin-callstack:fix/define-avatar-identity-and-fallback-semantics

Conversation

@oleksandrzavarzin-callstack

Copy link
Copy Markdown

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)

  • Colors: Custom string backgrounds still use the luminance heuristic. Opaque / dynamic values (PlatformColor, DynamicColorIOS) go through contentColorFor — a theme-role token pairs with its on- color; anything else falls back to onSurface. Explicit color still wins. getContrastingColor is string-only, so it no longer guesses white for unresolved dynamic colors.
  • Accessibility (Avatar.Image): a11y props land on the rendered Image (or function source), not the wrapper View. An unlabeled / non-actionable avatar stays unfocusable (accessible={false}). When fallback is showing, a11y props move to the host so the replacement content is announced.
  • Accessibility (Avatar.Text): initials are hidden from assistive tech (accessibilityElementsHidden / importantForAccessibility="no-hide-descendants") so a parent label is not double-read.
  • Initials: takeGraphemes(label, 2) — combining marks, emoji (ZWJ / skin tones), and flag sequences stay intact; long names are bounded to two graphemes.
  • Fallback API: fallback={({ size }) => …} when the image fails. Function source now 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.
  • Docs: fallback + host size/style guidance

Visual / behavioral changes

  • Custom PlatformColor / dynamic backgrounds no longer get white content by default.
  • Avatar.Text shows at most two graphemes (emoji-safe); previously sliced by UTF-16 code unit.
  • Image a11y is announced on the image, not a duplicate wrapper focus target.
  • Failed images can swap to fallback instead of staying empty/broken.

Public API

  • New: Avatar.Image fallback?: (props: { size: number }) => React.ReactNode
  • Extended: function source props now include style and onError (plus a11y). Existing { size } usage still works.

Related issue

(new — to be created) · overlaps #5016 / #4990

Test plan

  • yarn typescript
  • yarn lint
  • yarn test
  • Visual verification (yarn example android):
    • Avatar.Text — default tonal pair; custom hex still contrasts; PlatformColor stays legible (not forced white); long names / emoji / combining marks show as two graphemes
    • Avatar.Icon — default + custom background/color still readable
    • Avatar.Image — a11y label is on the image; invalid URI shows fallback; function source fills the circle via host style; new URI retries, same URI keeps fallback

Screenshots:

  1. Custom / dynamic background — platformColor, hex, palette color; content color is readable
Screenshot 2026-08-21 at 12 00 00
  1. Grapheme initials — long name (Jane Doe), combining mark (éva), family emoji; content truncated to 2 graphemes
Screenshot 2026-08-21 at 11 57 34
  1. Image fallback — no image no fallback, no image with fallback (Avatar.text), with original image; broken URI → custom component
Screenshot 2026-08-21 at 12 05 30

Comment thread src/components/Avatar/AvatarText.tsx Outdated
Comment on lines +107 to +108
accessibilityElementsHidden
importantForAccessibility="no-hide-descendants"

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

        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).

Comment thread src/components/Avatar/AvatarImage.tsx Outdated
Comment on lines +139 to +142
const imageA11y =
Object.keys(accessibilityProps).length > 0
? accessibilityProps
: { accessible: false as const };

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  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 accessibleImage.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.

Comment thread src/utils/splitAccessibilityProps.ts Outdated
*/
const ACCESSIBILITY_PROP_KEYS = Object.keys(
ACCESSIBILITY_PROP_PRESENCE
) as (keyof AccessibilityProps)[];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

    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;

Comment thread src/utils/splitAccessibilityProps.ts Outdated

const value = rest[key];
if (value !== undefined) {
(accessibilityProps as Record<keyof AccessibilityProps, unknown>)[key] =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Comment thread src/utils/splitAccessibilityProps.ts Outdated

return {
accessibilityProps,
rest: rest as Omit<T, keyof AccessibilityProps>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants