Skip to content

feat(Avatar): optional href/onClick interactivity + roving focus in AvatarGroup (#4170)#4225

Draft
cixzhang wants to merge 1 commit into
mainfrom
feat/avatar-interactive-roving
Draft

feat(Avatar): optional href/onClick interactivity + roving focus in AvatarGroup (#4170)#4225
cixzhang wants to merge 1 commit into
mainfrom
feat/avatar-interactive-roving

Conversation

@cixzhang

Copy link
Copy Markdown
Contributor

What

Adds optional interactivity to Avatar and single-tab-stop roving focus to AvatarGroup.

Avatar — a href/onClick element-swap that mirrors Button exactly:

  • href set → renders a link via the useLinkComponent(as) hook (respects LinkProvider; never a hardcoded <a>). as/target/rel are forwarded.
  • else onClick set → renders a <button type="button">.
  • else → today's static element, unchanged (role="img", or role="presentation" + aria-hidden when decorative). Non-breaking default.

Prop names are copied verbatim from Button (href/as/target/rel/onClick) — no interactive/clickable mode prop. Interactive avatars get the system focus-visible accent ring and a real accessible name (alt/name).

AvatarGroup — when it contains interactive avatars (or an interactive AvatarGroupOverflow), it becomes a single Tab stop with roving ArrowLeft/ArrowRight focus, and announces a screen-reader keyboard hint. A purely static facepile is untouched.

Why

Avatars are frequently linked to profiles or used as buttons, and consumers have been wrapping them in their own <a>/<button> — which drops the focus ring, the accessible name, and (in a group) tab-management. Baking the exact Button trichotomy in keeps the API familiar and the a11y correct.

How

  • Reuses the Button trichotomy. Same resolution order, same prop names, same useLinkComponent hook for router-aware links. No new mode prop — the presence of href/onClick drives the rendered element.
  • Single-tab-stop roving via the existing useListFocus({hasRovingTabIndex, orientation: 'horizontal', isRtl, itemSelector}). The hook already owns the roving tabindex (one tabindex=0, the rest -1) and is the same primitive ButtonGroup/SegmentedControl/Toolbar use — no new mode was added.
  • data-avatar-item marker as the itemSelector. Roving selects on this marker (stamped on the rendered interactive <a>/<button> and on AvatarGroupOverflow's button), not a tag/role selector — so a nested <button> inside a custom status/badge slot never joins the roving order.
  • Keyboard hint is a visually-hidden instruction linked via aria-describedby on the role="group" root, sourced from a new i18n key (@astryx.avatarGroup.keyboardHint). It is only rendered/attached when the group actually has interactive children — no hint on a static facepile.
  • RTL is derived from the rendered container the way the codebase reads direction elsewhere (getComputedStyle(el).direction === 'rtl'), then passed to useListFocus.
  • Dev warning (client-safe). An interactive avatar with no accessible name emits a plain console.warn — matching the established client-safe warning pattern in sibling components (Field/Timestamp/Popover). It is not gated on process.env (not available on the client in this codebase).

Deferred

  • Async clickAction (Button's async prop) is intentionally not added here — it's a follow-up.

Testing

  • Avatar: renders link (href) / button (onClick) / static (neither); href wins over onClick; focus + ring on interactive; data-avatar-item only on interactive; dev-warn on interactive-without-name; no warn for static-decorative.
  • AvatarGroup: single tab stop; arrow roving over interactive avatars + the overflow button; roving skips a non-avatar button in a status slot; static group has no tab stop and no hint; the aria-describedby hint appears only with interactive children.
  • All pre-existing Avatar/AvatarGroup tests stay green.

Closes #4170

@vercel

vercel Bot commented Jul 23, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview, Comment Jul 23, 2026 12:36am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Jul 23, 2026
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Jul 23, 2026
@cixzhang

Copy link
Copy Markdown
Contributor Author

Self-review — Quick Self-Review checklist (Build Protocol)

  1. Icons from registry — no icons added; the existing DefaultIcon fallback is unchanged. ✅
  2. themeProps present — every root variant (link / button / static) goes through the same mergeProps(themeProps('avatar', {size}), stylex.props(...), className, style), so the interactive <a>/<button> carries astryx-avatar for theming. AvatarGroup keeps themeProps('avatar-group', {size}). ✅
  3. No wrapper divs — the interactivity lives on the root element itself (the <a>/<button> replaces the root <div> — no extra wrapper). Handlers/refs are on that element. The inner visual <div> structure is unchanged from before. ✅
  4. No CSS shorthands — the new interactive StyleX uses explicit outlineWidth/outlineStyle/outlineColor/outlineOffset and explicit border-reset longhands, not shorthands. ✅
  5. ARIA still correct — static default is byte-for-byte unchanged (role="img" / presentation+aria-hidden). Interactive elements are real <a>/<button> with an aria-label accessible name. Group gains aria-describedby → visually-hidden i18n hint, only when interactive children exist. Roving uses the shared useListFocus primitive (same as ButtonGroup/SegmentedControl/Toolbar). ✅
  6. Tests pass locally — Avatar + AvatarGroup suites: 42 tests green (23 pre-existing + 19 new). ✅
  7. Sandbox/stories updated — added an Interactive story to both Avatar and AvatarGroup; static facepile story added for contrast; no props removed. ✅
  8. Popover background — N/A (no popover/layer introduced; the SR hint is a VisuallyHidden, not a layer).

Spec compliance notes

  • Element-swap trichotomy + verbatim Button prop names (href/as/target/rel/onClick), useLinkComponent for links, no mode prop. ✅
  • Roving is default-on via useListFocus({hasRovingTabIndex:true, orientation:'horizontal', isRtl, itemSelector:'[data-avatar-item]'}) — reused, no new hook mode. ✅
  • data-avatar-item marker (not tag/role) so roving skips nested buttons in a status/badge slot — covered by a test. ✅
  • Keyboard hint via aria-describedby from a new i18n key @astryx.avatarGroup.keyboardHint, attached only when interactive children exist. ✅
  • Async clickAction deferred (not added). ✅
  • Client-safe dev-warn: plain console.warn, matching the established pattern in Field/Timestamp/Popover — not gated on process.env (not available on the client here). ✅

Deviation (justified): changeset bump is patch, not minor. Core is 0.1.7 and the repo's check:changesets gate enforces pre-1.0 patch-only (a minor fails CI). The [feat] category tag captures the new-feature nature independently of semver.

@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

Avatar · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 458 -
Complexity N/A Very High (44) -
AvatarGroup · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 262 -
Complexity N/A Medium (15) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.7KB 0B

Accessibility Audit

Status: 2 accessibility violation(s) found — 2 serious.

Avatar - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 3/13 stories · Learn more
    • WCAG: 1.4.3 (Level AA)
AvatarGroup - 1 issue(s)
  • 🟠 serious: Ensure the contrast between foreground and background colors meets WCAG 2 AA minimum contrast ratio thresholds
    • Rule: color-contrast · Affects 1/15 stories · Learn more
    • WCAG: 1.4.3 (Level AA)

Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

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

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Avatar: optional interactivity (href/onClick) + single-tab-stop roving focus in AvatarGroup

1 participant